diff --git a/oci/remote/oci_installation_state.py b/oci/remote/oci_installation_state.py index dc8eac2b..96cdddda 100644 --- a/oci/remote/oci_installation_state.py +++ b/oci/remote/oci_installation_state.py @@ -166,6 +166,7 @@ def resolve_candidate(reference, architecture): if '@' in reference: transport = repo + '@' + reference.split('@', 1)[1] raw = command('skopeo', 'inspect', '--raw', 'docker://' + transport) + registry_digest = 'sha256:' + sha(raw) manifest = json.loads(raw) if 'manifests' in manifest: matches = [m for m in manifest['manifests'] if m.get('platform', {}).get('architecture') == architecture @@ -192,7 +193,8 @@ def resolve_candidate(reference, architecture): # The build date identifies the image as the publisher released it: it is # what changes when an image is rebuilt, whether or not the application # version inside it moved. - return {'manifest_digest': digest, 'layers': [layer['digest'] for layer in json.loads(raw).get('layers', [])], + return {'manifest_digest': digest, 'registry_digest': registry_digest, + 'layers': [layer['digest'] for layer in json.loads(raw).get('layers', [])], 'defaults': defaults, 'version': version, 'created': config.get('created')} diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py index 25bd8de5..e7efcad0 100644 --- a/oci/remote/oci_update_current.py +++ b/oci/remote/oci_update_current.py @@ -67,7 +67,10 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N digest = candidate['manifest_digest'] if not re.fullmatch(r'sha256:[a-f0-9]{64}', digest): raise ValueError(translate('Invalid registry digest')) - if required_digest is not None and digest != required_digest: + registry_digest = candidate.get('registry_digest') + if registry_digest is not None and not re.fullmatch(r'sha256:[a-f0-9]{64}', registry_digest): + raise ValueError(translate('Invalid registry digest')) + if required_digest is not None and registry_digest != required_digest: raise ValueError(translate('The registry did not return the saved image digest')) msg_ok(f"{translate('Image:')} {reference} ({candidate.get('version') or digest[7:19]})") if current and same_image(candidate, current): diff --git a/oci/tests/test_recreate_registry_digest.py b/oci/tests/test_recreate_registry_digest.py new file mode 100644 index 00000000..0848744e --- /dev/null +++ b/oci/tests/test_recreate_registry_digest.py @@ -0,0 +1,58 @@ +"""A Recreate pins the registry/index digest while downloading a platform manifest.""" + +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "remote")) + +import oci_installation_state as state +import oci_update_current as update + + +def digest(data): + return "sha256:" + state.sha(data) + + +class RecreateRegistryDigestTests(unittest.TestCase): + def test_candidate_keeps_the_multi_arch_registry_digest_separate_from_platform_manifest(self): + platform = json.dumps({ + "schemaVersion": 2, + "config": {"digest": "sha256:" + "c" * 64}, + "layers": [], + }).encode() + index = json.dumps({ + "schemaVersion": 2, + "manifests": [{"digest": digest(platform), + "platform": {"architecture": "amd64", "os": "linux"}}], + }).encode() + config = json.dumps({"architecture": "amd64", "os": "linux", + "config": {"Labels": {}, "Env": []}}).encode() + with patch.object(state, "command", side_effect=[index, platform, config]): + candidate = state.resolve_candidate("postgres@" + digest(index), "amd64") + + self.assertEqual(candidate["registry_digest"], digest(index)) + self.assertEqual(candidate["manifest_digest"], digest(platform)) + + def test_recreate_accepts_saved_index_digest_and_returns_platform_manifest(self): + index_digest = "sha256:" + "a" * 64 + platform_digest = "sha256:" + "b" * 64 + candidate = {"registry_digest": index_digest, "manifest_digest": platform_digest, + "layers": [], "created": "2026-10-05T00:00:00Z"} + desired = {"template": {"container_contract": {"image": {"reference": "postgres:latest"}}}, + "deployment": {"template_storage": "local"}} + with patch.object(update, "run_quiet", return_value=json.dumps(candidate)) as query: + archive, digest_value = update.resolve_archive( + desired, b"arch: amd64\n", current={"manifest_digest": platform_digest}, + required_digest=index_digest) + + self.assertIsNone(archive) + self.assertEqual(digest_value, platform_digest) + self.assertIn("postgres@" + index_digest, query.call_args.args[0]) + + +if __name__ == "__main__": + unittest.main()