Skip to content

Commit e34871c

Browse files
committed
Adding helper functions for index.db caching
[CLOUDDST-28870] Adding helper functions for index.db caching Assisted by: Gemini [CLOUDDST-28870]
1 parent 8ba03be commit e34871c

File tree

4 files changed

+286
-12
lines changed

4 files changed

+286
-12
lines changed

iib/workers/tasks/oras_utils.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from iib.common.tracing import instrument_tracing
1010
from iib.exceptions import IIBError
11-
from iib.workers.tasks.utils import run_cmd, set_registry_auths
11+
from iib.workers.tasks.utils import run_cmd, set_registry_auths, get_image_digest
1212

1313
log = logging.getLogger(__name__)
1414

@@ -102,3 +102,75 @@ def push_oras_artifact(
102102
log.info('Successfully pushed OCI artifact to %s', artifact_ref)
103103
except Exception as e:
104104
raise IIBError(f'Failed to push OCI artifact to {artifact_ref}: {e}')
105+
106+
107+
def get_image_stream_digest(
108+
tag: str,
109+
):
110+
"""
111+
Retrieve the image digest from the OpenShift ImageStream.
112+
113+
This function queries the `index-db-cache` ImageStream to get the
114+
SHA256 digest for a specific tag.
115+
116+
:param tag: The image tag to check.
117+
:return: The image digest (e.g., "sha256:...").
118+
:rtype: str
119+
"""
120+
jsonpath = f'\'{{.status.tags[?(@.tag=="{tag}")].items[0].image}}\''
121+
return run_cmd(
122+
['oc', 'get', 'imagestream', 'index-db-cache', '-o', f'jsonpath={jsonpath}'],
123+
exc_msg=f'Failed to get digest for ImageStream tag {tag}.',
124+
)
125+
126+
127+
def verify_indexdb_cache_sync(tag: str) -> bool:
128+
"""
129+
Compare the digest of the ImageStream with the digest of the image in repository.
130+
131+
This function verifies if the local ImageStream cache is up to date with
132+
the latest image in the remote registry.
133+
134+
:param tag: The image tag to verify.
135+
:return: True if the digests match (cache is synced), False otherwise.
136+
:rtype: bool
137+
"""
138+
# TODO - This can be loaded from config variable
139+
repository = "quay.io/my-org/index-db"
140+
141+
quay_digest = get_image_digest(f"{repository}:{tag}")
142+
is_digest = get_image_stream_digest(tag)
143+
144+
return quay_digest == is_digest
145+
146+
147+
def refresh_indexdb_cache(
148+
tag: str,
149+
registry_auths: Optional[Dict[str, Any]] = None,
150+
) -> None:
151+
"""
152+
Force a synchronization of the ImageStream with the remote registry.
153+
154+
This function imports the specified image from Quay.io into the `index-db-cache`
155+
ImageStream, ensuring the local cache is up-to-date.
156+
157+
:param tag: The container image tag to refresh.
158+
:param registry_auths: Optional authentication data for the registry.
159+
"""
160+
log.info('Refreshing OCI artifact cache: %s', tag)
161+
162+
# TODO - This can be loaded from config variable
163+
repository = "quay.io/my-org/index-db"
164+
165+
# Use namespace-specific registry authentication if provided
166+
with set_registry_auths(registry_auths, use_empty_config=True):
167+
run_cmd(
168+
[
169+
'oc',
170+
'import-image',
171+
f'index-db-cache:{tag}',
172+
f'--from={repository}:{tag}',
173+
'--confirm',
174+
],
175+
exc_msg=f'Failed to refresh OCI artifact {tag}.',
176+
)

iib/workers/tasks/utils.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,27 @@ def _get_container_image_name(pull_spec: str) -> str:
503503
return pull_spec.rsplit(':', 1)[0]
504504

505505

506+
def get_image_digest(pull_spec: str) -> str:
507+
"""
508+
Get the digest of the image defined by pull_spec.
509+
510+
:param str pull_spec: the pull specification of the container image
511+
:return: the digest of the image
512+
:rtype: str
513+
"""
514+
skopeo_output = skopeo_inspect(f'docker://{pull_spec}', '--raw', return_json=False)
515+
if json.loads(skopeo_output).get('schemaVersion') == 2:
516+
raw_digest = hashlib.sha256(skopeo_output.encode('utf-8')).hexdigest()
517+
return f'sha256:{raw_digest}'
518+
519+
# Schema 1 is not a stable format. The contents of the manifest may change slightly
520+
# between requests causing a different digest to be computed. Instead, let's leverage
521+
# skopeo's own logic for determining the digest in this case. In the future, we
522+
# may want to use skopeo in all cases, but this will have significant performance
523+
# issues until https://github.com/containers/skopeo/issues/785
524+
return skopeo_inspect(f'docker://{pull_spec}')['Digest']
525+
526+
506527
def get_resolved_image(pull_spec: str) -> str:
507528
"""
508529
Get the pull specification of the container image using its digest.
@@ -513,17 +534,7 @@ def get_resolved_image(pull_spec: str) -> str:
513534
"""
514535
log.debug('Resolving %s', pull_spec)
515536
name = _get_container_image_name(pull_spec)
516-
skopeo_output = skopeo_inspect(f'docker://{pull_spec}', '--raw', return_json=False)
517-
if json.loads(skopeo_output).get('schemaVersion') == 2:
518-
raw_digest = hashlib.sha256(skopeo_output.encode('utf-8')).hexdigest()
519-
digest = f'sha256:{raw_digest}'
520-
else:
521-
# Schema 1 is not a stable format. The contents of the manifest may change slightly
522-
# between requests causing a different digest to be computed. Instead, let's leverage
523-
# skopeo's own logic for determining the digest in this case. In the future, we
524-
# may want to use skopeo in all cases, but this will have significant performance
525-
# issues until https://github.com/containers/skopeo/issues/785
526-
digest = skopeo_inspect(f'docker://{pull_spec}')['Digest']
537+
digest = get_image_digest(pull_spec)
527538
pull_spec_resolved = f'{name}@{digest}'
528539
log.debug('%s resolved to %s', pull_spec, pull_spec_resolved)
529540
return pull_spec_resolved

tests/test_workers/test_tasks/test_oras_utils.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from iib.workers.tasks.oras_utils import (
99
get_oras_artifact,
1010
push_oras_artifact,
11+
verify_indexdb_cache_sync,
12+
get_image_stream_digest,
13+
refresh_indexdb_cache,
1114
)
1215

1316

@@ -326,3 +329,92 @@ def test_get_oras_artifact_with_base_dir_wont_leak_credentials(
326329
all_messages = ' '.join(caplog.messages)
327330
assert 'dXNlcjpwYXNz' not in all_messages # base64 encoded credentials
328331
assert 'user:pass' not in all_messages # decoded credentials
332+
333+
334+
@mock.patch('iib.workers.tasks.oras_utils.run_cmd')
335+
def test_get_image_stream_digest(mock_run_cmd):
336+
"""Test successful retrieval of image digest from ImageStream."""
337+
mock_run_cmd.return_value = 'sha256:12345'
338+
tag = 'test-tag'
339+
340+
digest = get_image_stream_digest(tag)
341+
342+
assert digest == 'sha256:12345'
343+
mock_run_cmd.assert_called_once_with(
344+
[
345+
'oc',
346+
'get',
347+
'imagestream',
348+
'index-db-cache',
349+
'-o',
350+
'jsonpath=\'{.status.tags[?(@.tag=="test-tag")].items[0].image}\'',
351+
],
352+
exc_msg='Failed to get digest for ImageStream tag test-tag.',
353+
)
354+
355+
356+
@mock.patch('iib.workers.tasks.oras_utils.run_cmd', side_effect=IIBError('cmd failed'))
357+
def test_get_image_stream_digest_failure(mock_run_cmd):
358+
"""Test failure during retrieval of image digest from ImageStream."""
359+
with pytest.raises(IIBError, match='cmd failed'):
360+
get_image_stream_digest('test-tag')
361+
362+
363+
@mock.patch('iib.workers.tasks.oras_utils.get_image_stream_digest')
364+
@mock.patch('iib.workers.tasks.oras_utils.get_image_digest')
365+
def test_verify_indexdb_cache_sync_match(mock_get_image_digest, mock_get_is_digest):
366+
"""Test successful verification when digests match."""
367+
mock_get_image_digest.return_value = 'sha256:abc'
368+
mock_get_is_digest.return_value = 'sha256:abc'
369+
tag = 'test-tag'
370+
371+
result = verify_indexdb_cache_sync(tag)
372+
373+
assert result is True
374+
mock_get_image_digest.assert_called_once_with('quay.io/my-org/index-db:test-tag')
375+
mock_get_is_digest.assert_called_once_with(tag)
376+
377+
378+
@mock.patch('iib.workers.tasks.oras_utils.get_image_stream_digest')
379+
@mock.patch('iib.workers.tasks.oras_utils.get_image_digest')
380+
def test_verify_indexdb_cache_sync_no_match(mock_get_image_digest, mock_get_is_digest):
381+
"""Test successful verification when digests don't match."""
382+
mock_get_image_digest.return_value = 'sha256:abc'
383+
mock_get_is_digest.return_value = 'sha256:xyz'
384+
tag = 'test-tag'
385+
386+
result = verify_indexdb_cache_sync(tag)
387+
388+
assert result is False
389+
mock_get_image_digest.assert_called_once_with('quay.io/my-org/index-db:test-tag')
390+
mock_get_is_digest.assert_called_once_with(tag)
391+
392+
393+
@mock.patch('iib.workers.tasks.oras_utils.set_registry_auths')
394+
@mock.patch('iib.workers.tasks.oras_utils.run_cmd')
395+
def test_refresh_indexdb_cache_success(mock_run_cmd, mock_auth, registry_auths):
396+
"""Test successful cache refresh."""
397+
tag = 'test-tag'
398+
399+
refresh_indexdb_cache(tag, registry_auths)
400+
401+
mock_auth.assert_called_once_with(registry_auths, use_empty_config=True)
402+
mock_run_cmd.assert_called_once_with(
403+
[
404+
'oc',
405+
'import-image',
406+
'index-db-cache:test-tag',
407+
'--from=quay.io/my-org/index-db:test-tag',
408+
'--confirm',
409+
],
410+
exc_msg='Failed to refresh OCI artifact test-tag.',
411+
)
412+
413+
414+
@mock.patch('iib.workers.tasks.oras_utils.run_cmd', side_effect=IIBError('refresh failed'))
415+
def test_refresh_indexdb_cache_failure(mock_run_cmd):
416+
"""Test cache refresh failure."""
417+
tag = 'test-tag'
418+
419+
with pytest.raises(IIBError, match='refresh failed'):
420+
refresh_indexdb_cache(tag)

tests/test_workers/test_tasks/test_utils.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# SPDX-License-Identifier: GPL-3.0-or-later
2+
import hashlib
3+
import json
24
import logging
35
import os
46
import stat
@@ -528,6 +530,103 @@ def mock_handler(spam, eggs, request_id, bacon):
528530
assert not logs_dir.listdir()
529531

530532

533+
@mock.patch('iib.workers.tasks.utils.skopeo_inspect')
534+
def test_get_image_digest_schema_2(mock_skopeo_inspect):
535+
"""
536+
Tests the get_image_digest function for an image with schemaVersion 2.
537+
538+
Verifies that the function correctly calculates the SHA256 digest from the raw JSON output.
539+
"""
540+
# Simulate raw JSON output from skopeo inspect for schema v2
541+
raw_output = textwrap.dedent(
542+
"""
543+
{
544+
"schemaVersion": 2,
545+
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
546+
"config": {
547+
"mediaType": "application/vnd.docker.container.image.v1+json",
548+
"size": 5545,
549+
"digest": "sha256:720713e1a4410985aacd7008719efd13d8a32e76d08d34fca202a60ff43e516d"
550+
}
551+
}
552+
"""
553+
).strip()
554+
555+
# Configure the mock object to return the raw output
556+
mock_skopeo_inspect.return_value = raw_output
557+
558+
# Expected digest (SHA256 hash of the raw_output)
559+
expected_digest = f"sha256:{hashlib.sha256(raw_output.encode('utf-8')).hexdigest()}"
560+
561+
# Run the function under test
562+
actual_digest = utils.get_image_digest("some-image:latest")
563+
564+
# Verify that skopeo_inspect was called with the correct parameters
565+
mock_skopeo_inspect.assert_called_once_with(
566+
"docker://some-image:latest", "--raw", return_json=False
567+
)
568+
569+
# Verify the result
570+
assert actual_digest == expected_digest
571+
572+
573+
@mock.patch('iib.workers.tasks.utils.skopeo_inspect')
574+
def test_get_image_digest_schema_1(mock_skopeo_inspect):
575+
"""
576+
Tests the get_image_digest function for an image with schemaVersion 1.
577+
578+
Verifies that the function correctly returns the digest from the parsed JSON output
579+
(the second skopeo_inspect call).
580+
"""
581+
# Simulate two different skopeo_inspect calls with different return values
582+
mock_skopeo_inspect.side_effect = [
583+
# First call (with --raw) to check the schema
584+
'{"schemaVersion": 1, "name": "repository/name"}',
585+
# Second call (without --raw) to get the digest
586+
{
587+
"Name": "registry.example.com/repository/name",
588+
"Digest": "sha256:expected-digest-from-skopeo-output",
589+
},
590+
]
591+
592+
# Expected digest from the second mock call
593+
expected_digest = "sha256:expected-digest-from-skopeo-output"
594+
595+
# Run the function under test
596+
actual_digest = utils.get_image_digest("registry.example.com/repository/name:1.0.0")
597+
598+
# Verify that the skopeo_inspect function was called twice with the correct parameters
599+
mock_skopeo_inspect.assert_has_calls(
600+
[
601+
mock.call(
602+
"docker://registry.example.com/repository/name:1.0.0", "--raw", return_json=False
603+
),
604+
mock.call("docker://registry.example.com/repository/name:1.0.0"),
605+
]
606+
)
607+
608+
# Verify the result
609+
assert actual_digest == expected_digest
610+
611+
612+
@mock.patch('iib.workers.tasks.utils.skopeo_inspect')
613+
def test_get_image_digest_invalid_json(mock_skopeo_inspect):
614+
"""
615+
Tests get_image_digest for invalid JSON output.
616+
617+
Verifies that the function correctly raises an error if the skopeo output is invalid.
618+
"""
619+
# Simulate invalid JSON output
620+
mock_skopeo_inspect.return_value = "This is not valid JSON"
621+
622+
# We expect the function to raise a ValueError (from json.loads)
623+
with pytest.raises(json.JSONDecodeError):
624+
utils.get_image_digest("invalid-image:latest")
625+
626+
# Verify that skopeo_inspect was called
627+
mock_skopeo_inspect.assert_called_once()
628+
629+
531630
@pytest.mark.parametrize(
532631
'pull_spec, expected',
533632
(

0 commit comments

Comments
 (0)