Repository: canonical/charmcraft

Issue:
The upload-resource command should accept images id or digests
(all this is for OCI images, file resources are fine)

Currently is mixed: accepts "ids" (and no point in checking remotely in that case) but the docs say "digest".

It should:

- accept "id" or digest, these two should be fine: `charmcraft upload-resource mychar myresource ...`
    `--image=3e42dd4e79c7`
    `--image=sha256:9bc34afe08ca30ef179404318cdebe6430ceda35a4ebe4b67d10789b17bdf7c4`
- the distinction between id or digest should happen checking if it starts with `sha`
- if it's an ID
    - just upload from local (no point in checking remotely); however inform it in "debug"
    - get the image info from local dockerd as it currently does
- if it's a Digest
    - do the "check remote, if not there check local" dance as today
    - get the image info from local dockerd searching *all* the images (`/images/json`) and checking which one (if any) has the indicated digest in one of the `RepoDigests` items
- command documentation should be improved to reflect this
- forum post about uploading resource should also be improved

Discussion:

Patch:
diff --git a/charmcraft/commands/store/__init__.py b/charmcraft/commands/store/__init__.py
index bd987380..26ab8dd4 100644
--- a/charmcraft/commands/store/__init__.py
+++ b/charmcraft/commands/store/__init__.py
@@ -44,7 +44,7 @@ from charmcraft.utils import (
 )
 
 from .store import Store
-from .registry import ImageHandler, OCIRegistry
+from .registry import ImageHandler, OCIRegistry, LocalDockerdInterface
 
 # some types
 EntityType = namedtuple("EntityType", "charm bundle")(charm="charm", bundle="bundle")
@@ -1704,8 +1704,8 @@ class UploadResourceCommand(BaseCommand):
 
         The resource can be a file from your computer (use the `--filepath`
         option) or an OCI Image (use the `--image` option to indicate the
-        image digest), which can be already in Canonical's registry and
-        used directly, or locally in your computer and will be uploaded
+        image digest or id), which can be already in Canonical's registry
+        and used directly, or locally in your computer and will be uploaded
         and used.
 
         Upload will take you through login if needed.
@@ -1731,7 +1731,7 @@ class UploadResourceCommand(BaseCommand):
         group.add_argument(
             "--image",
             type=SingleOptionEnsurer(str),
-            help="The digest of the OCI image",
+            help="The digest (remote or local) or id (local) of the OCI image",
         )
 
     def run(self, parsed_args):
@@ -1744,7 +1744,6 @@ class UploadResourceCommand(BaseCommand):
             resource_type = ResourceType.file
             emit.progress(f"Uploading resource directly from file {str(resource_filepath)!r}.")
         elif parsed_args.image:
-            image_digest = parsed_args.image
             credentials = store.get_oci_registry_credentials(
                 parsed_args.charm_name, parsed_args.resource_name
             )
@@ -1753,9 +1752,9 @@ class UploadResourceCommand(BaseCommand):
             # 'registry.jujucharms.com/charm/45kk8smbiyn2e/redis-image') to the image
             # name that we use internally (just remove the initial "server host" part)
             image_name = credentials.image_name.split("/", 1)[1]
-            emit.progress(f"Uploading resource from image {image_name} @ {image_digest}.")
+            emit.progress(f"Uploading resource from image {image_name} @ {parsed_args.image}.")
 
-            # build the image handler
+            # build the image handler and dockerd interface
             registry = OCIRegistry(
                 self.config.charmhub.registry_url,
                 image_name,
@@ -1763,31 +1762,40 @@ class UploadResourceCommand(BaseCommand):
                 password=credentials.password,
             )
             ih = ImageHandler(registry)
+            dockerd = LocalDockerdInterface()
+
+            server_image_digest = None
+            if ":" in parsed_args.image:
+                # the user provided a digest; check if the specific image is
+                # already in Canonical's registry
+                already_uploaded = ih.check_in_registry(parsed_args.image)
+
+                if already_uploaded:
+                    emit.progress("Using OCI image from Canonical's registry.", permanent=True)
+                    server_image_digest = parsed_args.image
+                else:
+                    emit.progress("Remote image not found, getting its info from local registry.")
+                    image_info = dockerd.get_image_info_from_digest(parsed_args.image)
 
-            # check if the specific image is already in Canonical's registry
-            already_uploaded = ih.check_in_registry(image_digest)
-            if already_uploaded:
-                emit.progress("Using OCI image from Canonical's registry.", permanent=True)
             else:
+                # the user provided an id, can't search remotely, just get its info locally
+                emit.progress("Getting image info from local registry.")
+                image_info = dockerd.get_image_info_from_id(parsed_args.image)
+
+            if server_image_digest is None:
+                if image_info is None:
+                    raise CraftError("Image not found locally.")
+
                 # upload it from local registry
+                emit.progress("Uploading from local registry.", permanent=True)
+                server_image_digest = ih.upload_from_local(image_info)
                 emit.progress(
-                    "Remote image not found, uploading from local registry.", permanent=True
-                )
-                image_digest = ih.upload_from_local(image_digest)
-                if image_digest is None:
-                    emit.progress(
-                        f"Image with digest {parsed_args.image} is not available in "
-                        "the Canonical's registry nor locally.",
-                        permanent=True,
-                    )
-                    return
-                emit.progress(
-                    f"Image uploaded, new remote digest: {image_digest}.", permanent=True
+                    f"Image uploaded, new remote digest: {server_image_digest}.", permanent=True
                 )
 
             # all is green, get the blob to upload to Charmhub
             content = store.get_oci_image_blob(
-                parsed_args.charm_name, parsed_args.resource_name, image_digest
+                parsed_args.charm_name, parsed_args.resource_name, server_image_digest
             )
             tfd, tname = tempfile.mkstemp(prefix="image-resource", suffix=".json")
             with open(tfd, "wt", encoding="utf-8") as fh:  # reuse the file descriptor and close it
diff --git a/charmcraft/commands/store/registry.py b/charmcraft/commands/store/registry.py
index a2ac16ba..96b65a8f 100644
--- a/charmcraft/commands/store/registry.py
+++ b/charmcraft/commands/store/registry.py
@@ -290,12 +290,12 @@ class LocalDockerdInterface:
     def __init__(self):
         self.session = requests_unixsocket.Session()
 
-    def get_image_info(self, digest: str) -> Union[dict, None]:
-        """Get the info for a specific image.
+    def get_image_info_from_id(self, image_id: str) -> Union[dict, None]:
+        """Get the info for a specific image using its id.
 
-        Returns None to flag that the requested digest was not found by any reason.
+        Returns None to flag that the requested id was not found for any reason.
         """
-        url = self.dockerd_socket_baseurl + "/images/{}/json".format(digest)
+        url = self.dockerd_socket_baseurl + "/images/{}/json".format(image_id)
         try:
             response = self.session.get(url)
         except requests.exceptions.ConnectionError:
@@ -311,11 +311,33 @@ class LocalDockerdInterface:
         # 404 is the standard response to "not found", if not exactly that let's log
         # for proper debugging
         if response.status_code != 404:
-            emit.debug(f"Bad response when validation local image: {response.status_code}")
+            emit.debug(f"Bad response when validating local image: {response.status_code}")
 
-    def get_streamed_image_content(self, digest: str) -> requests.Response:
+    def get_image_info_from_digest(self, digest: str) -> Union[dict, None]:
+        """Get the info for a specific image using its digest.
+
+        Returns None to flag that the requested digest was not found for any reason.
+        """
+        url = self.dockerd_socket_baseurl + "/images/json"
+        try:
+            response = self.session.get(url)
+        except requests.exceptions.ConnectionError:
+            emit.debug(
+                "Cannot connect to /var/run/docker.sock , please ensure dockerd is running.",
+            )
+            return
+
+        if response.status_code != 200:
+            emit.debug(f"Bad response when validating local image: {response.status_code}")
+            return
+
+        for image_info in response.json():
+            if any(digest in repo_digest for repo_digest in image_info["RepoDigests"]):
+                return image_info
+
+    def get_streamed_image_content(self, image_id: str) -> requests.Response:
         """Stream the content of a specific image."""
-        url = self.dockerd_socket_baseurl + "/images/{}/get".format(digest)
+        url = self.dockerd_socket_baseurl + "/images/{}/get".format(image_id)
         return self.session.get(url, stream=True)
 
 
@@ -373,22 +395,17 @@ class ImageHandler:
         # finally remove the temp filepath
         os.unlink(filepath)
 
-    def upload_from_local(self, digest: str) -> Union[str, None]:
+    def upload_from_local(self, image_info: Dict[str, Any]) -> Union[str, None]:
         """Upload the image from the local registry.
 
-        Returns the new remote digest, or None if the image was not found locally.
+        Returns the new remote digest.
         """
         dockerd = LocalDockerdInterface()
-
-        # validate the image is present locally
-        emit.progress("Checking image is present locally")
-        image_info = dockerd.get_image_info(digest)
-        if image_info is None:
-            return
         local_image_size = image_info["Size"]
+        image_id = image_info["Id"]
 
         emit.progress(f"Getting the image from the local repo; size={local_image_size}")
-        response = dockerd.get_streamed_image_content(digest)
+        response = dockerd.get_streamed_image_content(image_id)
 
         tmp_exported = tempfile.NamedTemporaryFile(mode="wb", delete=False)
         with emit.progress_bar("Reading image...", local_image_size) as progress:


Test patch:
diff --git a/tests/commands/test_store_commands.py b/tests/commands/test_store_commands.py
index b9879b38..327866f4 100644
--- a/tests/commands/test_store_commands.py
+++ b/tests/commands/test_store_commands.py
@@ -4392,7 +4392,7 @@ def test_uploadresource_filepath_call_ok(emitter, store_mock, config, tmp_path,
 
 @pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
 @pytest.mark.parametrize("formatted", [None, JSON_FORMAT])
-def test_uploadresource_image_call_already_uploaded(emitter, store_mock, config, formatted):
+def test_uploadresource_image_digest_already_uploaded(emitter, store_mock, config, formatted):
     """Upload an oci-image resource, the image itself already being in the registry."""
     # fake credentials for the charm/resource, and the final json content
     store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
@@ -4401,7 +4401,7 @@ def test_uploadresource_image_call_already_uploaded(emitter, store_mock, config,
         image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name",
     )
 
-    test_json_content = "from charmhub we came, from charmhub we shall return"
+    test_json_content = "from charmhub we came, to charmhub we shall return"
     store_mock.get_oci_image_blob.return_value = test_json_content
 
     # hack into the store mock to save for later the uploaded resource bytes
@@ -4423,7 +4423,7 @@ def test_uploadresource_image_call_already_uploaded(emitter, store_mock, config,
     store_mock.upload_resource.side_effect = interceptor
 
     # test
-    original_image_digest = "test-digest-given-by-user"
+    original_image_digest = "sha256:test-digest-given-by-user"
     args = Namespace(
         charm_name="mycharm",
         resource_name="myresource",
@@ -4473,7 +4473,7 @@ def test_uploadresource_image_call_already_uploaded(emitter, store_mock, config,
                 call(
                     "progress",
                     "Uploading resource from image "
-                    "charm/charm-id/test-image-name @ test-digest-given-by-user.",
+                    "charm/charm-id/test-image-name @ sha256:test-digest-given-by-user.",
                 ),
                 call("progress", "Using OCI image from Canonical's registry.", permanent=True),
                 call(
@@ -4484,8 +4484,8 @@ def test_uploadresource_image_call_already_uploaded(emitter, store_mock, config,
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
-def test_uploadresource_image_call_upload_from_local(emitter, store_mock, config):
-    """Upload an oci-image resource, the image is upload from local to Canonical's registry."""
+def test_uploadresource_image_digest_upload_from_local(emitter, store_mock, config):
+    """Upload an oci-image resource, from local to Canonical's registry, specified by digest."""
     # fake credentials for the charm/resource, the final json content, and the upload result
     store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
         username="testusername",
@@ -4493,13 +4493,14 @@ def test_uploadresource_image_call_upload_from_local(emitter, store_mock, config
         image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name",
     )
 
-    test_json_content = "from charmhub we came, from charmhub we shall return"
+    test_json_content = "from charmhub we came, to charmhub we shall return"
     store_mock.get_oci_image_blob.return_value = test_json_content
 
     store_mock.upload_resource.return_value = Uploaded(ok=True, status=200, revision=7, errors=[])
 
     # test
-    original_image_digest = "test-digest-given-by-user"
+    original_image_digest = "sha256:test-digest-given-by-user"
+    local_image_info = "local image info"
     args = Namespace(
         charm_name="mycharm",
         resource_name="myresource",
@@ -4508,22 +4509,24 @@ def test_uploadresource_image_call_upload_from_local(emitter, store_mock, config
         format=False,
     )
     with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
-        with patch("charmcraft.commands.store.OCIRegistry", autospec=True) as reg_class_mock:
-            reg_class_mock.return_value = reg_mock = MagicMock()
+        with patch(
+            "charmcraft.commands.store.LocalDockerdInterface", autospec=True
+        ) as dockerd_class_mock:
             im_class_mock.return_value = im_mock = MagicMock()
+            dockerd_class_mock.return_value = dock_mock = MagicMock()
 
-            # not in the registry, and then uploaded ok
+            # not in the remote registry, found locally, then uploaded ok
             im_mock.check_in_registry.return_value = False
+            dock_mock.get_image_info_from_digest.return_value = local_image_info
             new_image_digest = "new-digest-after-upload"
             im_mock.upload_from_local.return_value = new_image_digest
 
             UploadResourceCommand(config).run(args)
 
     # validate how ImageHandler was used
-    assert im_class_mock.mock_calls == [
-        call(reg_mock),
-        call().check_in_registry(original_image_digest),
-        call().upload_from_local(original_image_digest),
+    assert im_mock.mock_calls == [
+        call.check_in_registry(original_image_digest),
+        call.upload_from_local(local_image_info),
     ]
 
     assert store_mock.mock_calls == [
@@ -4537,13 +4540,79 @@ def test_uploadresource_image_call_upload_from_local(emitter, store_mock, config
             call(
                 "progress",
                 "Uploading resource from image "
-                "charm/charm-id/test-image-name @ test-digest-given-by-user.",
+                "charm/charm-id/test-image-name @ sha256:test-digest-given-by-user.",
             ),
+            call("progress", "Remote image not found, getting its info from local registry."),
+            call("progress", "Uploading from local registry.", permanent=True),
             call(
                 "progress",
-                "Remote image not found, uploading from local registry.",
+                "Image uploaded, new remote digest: new-digest-after-upload.",
                 permanent=True,
             ),
+            call("message", "Revision 7 created of resource 'myresource' for charm 'mycharm'."),
+        ]
+    )
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
+def test_uploadresource_image_id_upload_from_local(emitter, store_mock, config):
+    """Upload an oci-image resource, from local to Canonical's registry, specified by id."""
+    # fake credentials for the charm/resource, the final json content, and the upload result
+    store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
+        username="testusername",
+        password="testpassword",
+        image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name",
+    )
+
+    test_json_content = "from charmhub we came, to charmhub we shall return"
+    store_mock.get_oci_image_blob.return_value = test_json_content
+
+    store_mock.upload_resource.return_value = Uploaded(ok=True, status=200, revision=7, errors=[])
+
+    # test
+    original_image_id = "test-id-given-by-user"
+    local_image_info = "local image info"
+    args = Namespace(
+        charm_name="mycharm",
+        resource_name="myresource",
+        filepath=None,
+        image=original_image_id,
+        format=False,
+    )
+    with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
+        with patch(
+            "charmcraft.commands.store.LocalDockerdInterface", autospec=True
+        ) as dockerd_class_mock:
+            im_class_mock.return_value = im_mock = MagicMock()
+            dockerd_class_mock.return_value = dock_mock = MagicMock()
+
+            # found locally, then uploaded ok
+            dock_mock.get_image_info_from_id.return_value = local_image_info
+            new_image_digest = "new-digest-after-upload"
+            im_mock.upload_from_local.return_value = new_image_digest
+
+            UploadResourceCommand(config).run(args)
+
+    # validate how ImageHandler was used
+    assert im_mock.mock_calls == [
+        call.upload_from_local(local_image_info),
+    ]
+
+    assert store_mock.mock_calls == [
+        call.get_oci_registry_credentials("mycharm", "myresource"),
+        call.get_oci_image_blob("mycharm", "myresource", new_image_digest),
+        call.upload_resource("mycharm", "myresource", "oci-image", ANY),
+    ]
+
+    emitter.assert_interactions(
+        [
+            call(
+                "progress",
+                "Uploading resource from image "
+                "charm/charm-id/test-image-name @ test-id-given-by-user.",
+            ),
+            call("progress", "Getting image info from local registry."),
+            call("progress", "Uploading from local registry.", permanent=True),
             call(
                 "progress",
                 "Image uploaded, new remote digest: new-digest-after-upload.",
@@ -4554,8 +4623,8 @@ def test_uploadresource_image_call_upload_from_local(emitter, store_mock, config
     )
 
 
-def test_uploadresource_image_call_missing_everywhere(emitter, store_mock, config):
-    """Upload an oci-image resource, but the image is not found remote nor locally."""
+def test_uploadresource_image_digest_missing_everywhere(emitter, store_mock, config):
+    """Upload an oci-image resource by digest, but the image is not found remote nor locally."""
     # fake credentials for the charm/resource, the final json content, and the upload result
     store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
         username="testusername",
@@ -4564,7 +4633,7 @@ def test_uploadresource_image_call_missing_everywhere(emitter, store_mock, confi
     )
 
     # test
-    original_image_digest = "test-digest-given-by-user"
+    original_image_digest = "sha256:test-digest-given-by-user"
     args = Namespace(
         charm_name="mycharm",
         resource_name="myresource",
@@ -4573,23 +4642,28 @@ def test_uploadresource_image_call_missing_everywhere(emitter, store_mock, confi
         format=False,
     )
     with patch("charmcraft.commands.store.ImageHandler", autospec=True) as im_class_mock:
-        with patch("charmcraft.commands.store.OCIRegistry", autospec=True) as reg_class_mock:
-            reg_class_mock.return_value = reg_mock = MagicMock()
+        with patch(
+            "charmcraft.commands.store.LocalDockerdInterface", autospec=True
+        ) as dockerd_class_mock:
             im_class_mock.return_value = im_mock = MagicMock()
+            dockerd_class_mock.return_value = dock_mock = MagicMock()
 
             # not in the remote registry, not locally either
             im_mock.check_in_registry.return_value = False
-            im_mock.upload_from_local.return_value = None
+            dock_mock.get_image_info_from_digest.return_value = None
 
-            UploadResourceCommand(config).run(args)
+            with pytest.raises(CraftError) as cm:
+                UploadResourceCommand(config).run(args)
 
-    # validate how ImageHandler was used
-    assert im_class_mock.mock_calls == [
-        call(reg_mock),
-        call().check_in_registry(original_image_digest),
-        call().upload_from_local(original_image_digest),
-    ]
+    assert str(cm.value) == "Image not found locally."
 
+    # validate how local interfaces and store was used
+    assert im_mock.mock_calls == [
+        call.check_in_registry(original_image_digest),
+    ]
+    assert dock_mock.mock_calls == [
+        call.get_image_info_from_digest(original_image_digest),
+    ]
     assert store_mock.mock_calls == [
         call.get_oci_registry_credentials("mycharm", "myresource"),
     ]
@@ -4599,19 +4673,59 @@ def test_uploadresource_image_call_missing_everywhere(emitter, store_mock, confi
             call(
                 "progress",
                 "Uploading resource from "
-                "image charm/charm-id/test-image-name @ test-digest-given-by-user.",
-            ),
-            call(
-                "progress",
-                "Remote image not found, uploading from local registry.",
-                permanent=True,
+                "image charm/charm-id/test-image-name @ sha256:test-digest-given-by-user.",
             ),
+            call("progress", "Remote image not found, getting its info from local registry."),
+        ]
+    )
+
+
+def test_uploadresource_image_id_missing(emitter, store_mock, config):
+    """Upload an oci-image resource by id, but the image is not found locally."""
+    # fake credentials for the charm/resource, the final json content, and the upload result
+    store_mock.get_oci_registry_credentials.return_value = RegistryCredentials(
+        username="testusername",
+        password="testpassword",
+        image_name="registry.staging.jujucharms.com/charm/charm-id/test-image-name",
+    )
+
+    # test
+    original_image_id = "test-id-given-by-user"
+    args = Namespace(
+        charm_name="mycharm",
+        resource_name="myresource",
+        filepath=None,
+        image=original_image_id,
+        format=False,
+    )
+    with patch(
+        "charmcraft.commands.store.LocalDockerdInterface", autospec=True
+    ) as dockerd_class_mock:
+        dockerd_class_mock.return_value = dock_mock = MagicMock()
+
+        # not present locally
+        dock_mock.get_image_info_from_id.return_value = None
+
+        with pytest.raises(CraftError) as cm:
+            UploadResourceCommand(config).run(args)
+
+    assert str(cm.value) == "Image not found locally."
+
+    assert dock_mock.mock_calls == [
+        call.get_image_info_from_id(original_image_id),
+    ]
+    assert store_mock.mock_calls == [
+        call.get_oci_registry_credentials("mycharm", "myresource"),
+    ]
+
+    emitter.assert_interactions(
+        [
             call(
                 "progress",
-                "Image with digest test-digest-given-by-user is not available in "
-                "the Canonical's registry nor locally.",
-                permanent=True,
+                "Uploading resource from "
+                "image charm/charm-id/test-image-name @ test-id-given-by-user.",
             ),
+            call("progress", "Getting image info from local registry."),
         ]
     )
 
diff --git a/tests/commands/test_store_registry.py b/tests/commands/test_store_registry.py
index 6fcd335e..1c77a2cd 100644
--- a/tests/commands/test_store_registry.py
+++ b/tests/commands/test_store_registry.py
@@ -736,55 +736,115 @@ def test_ociregistry_upload_blob_bad_final_digest(tmp_path, responses):
 # -- tests for the ImageHandler helpers and functionalities
 
 
-def test_localdockerinterface_get_info_ok(responses, emitter):
+def test_localdockerinterface_get_info_by_id_ok(responses, emitter):
     """Get image info ok."""
     test_image_info = {"some": "stuff"}
     responses.add(
         responses.GET,
-        LocalDockerdInterface.dockerd_socket_baseurl + "/images/test-digest/json",
+        LocalDockerdInterface.dockerd_socket_baseurl + "/images/test-id/json",
         json=test_image_info,
     )
     ldi = LocalDockerdInterface()
-    resp = ldi.get_image_info("test-digest")
+    resp = ldi.get_image_info_from_id("test-id")
     assert resp == test_image_info
 
     emitter.assert_interactions(None)
 
 
-def test_localdockerinterface_get_info_not_found(responses, emitter):
+def test_localdockerinterface_get_info_by_id_not_found(responses, emitter):
     """Get image info for something that is not there."""
     # return 404, which means that the image was not found
     responses.add(
         responses.GET,
-        LocalDockerdInterface.dockerd_socket_baseurl + "/images/test-digest/json",
+        LocalDockerdInterface.dockerd_socket_baseurl + "/images/test-id/json",
         status=404,
     )
     ldi = LocalDockerdInterface()
-    resp = ldi.get_image_info("test-digest")
+    resp = ldi.get_image_info_from_id("test-id")
     assert resp is None
 
     emitter.assert_interactions(None)
 
 
-def test_localdockerinterface_get_info_bad_response(responses, emitter):
+def test_localdockerinterface_get_info_by_id_bad_response(responses, emitter):
     """Docker answered badly when checking for the image."""
     # weird dockerd behaviour
     responses.add(
         responses.GET,
-        LocalDockerdInterface.dockerd_socket_baseurl + "/images/test-digest/json",
+        LocalDockerdInterface.dockerd_socket_baseurl + "/images/test-id/json",
         status=500,
     )
     ldi = LocalDockerdInterface()
-    resp = ldi.get_image_info("test-digest")
+    resp = ldi.get_image_info_from_id("test-id")
     assert resp is None
 
-    emitter.assert_debug("Bad response when validation local image: 500")
+    emitter.assert_debug("Bad response when validating local image: 500")
 
 
-def test_localdockerinterface_get_info_disconnected(emitter, responses):
+def test_localdockerinterface_get_info_by_id_disconnected(emitter, responses):
     """No daemon to talk to (see responses used as fixture but no listening)."""
     ldi = LocalDockerdInterface()
-    resp = ldi.get_image_info("test-digest")
+    resp = ldi.get_image_info_from_id("test-id")
+    assert resp is None
+
+    emitter.assert_debug(
+        "Cannot connect to /var/run/docker.sock , please ensure dockerd is running."
+    )
+
+
+def test_localdockerinterface_get_info_by_digest_ok(responses, emitter):
+    """Get image info ok."""
+    test_image_info_1 = {"some": "stuff", "RepoDigests": ["name @ sha256:test-digest", "other"]}
+    test_image_info_2 = {"some": "stuff", "RepoDigests": ["foo", "bar"]}
+    test_search_respoonse = [test_image_info_1, test_image_info_2]
+    responses.add(
+        responses.GET,
+        LocalDockerdInterface.dockerd_socket_baseurl + "/images/json",
+        json=test_search_respoonse,
+    )
+    ldi = LocalDockerdInterface()
+    resp = ldi.get_image_info_from_digest("sha256:test-digest")
+    assert resp == test_image_info_1
+
+    emitter.assert_interactions(None)
+
+
+def test_localdockerinterface_get_info_by_digest_not_found(responses, emitter):
+    """Get image info for something that is not there."""
+    test_image_info_1 = {"some": "stuff", "RepoDigests": ["other"]}
+    test_image_info_2 = {"some": "stuff", "RepoDigests": ["foo", "bar"]}
+    test_search_respoonse = [test_image_info_1, test_image_info_2]
+    responses.add(
+        responses.GET,
+        LocalDockerdInterface.dockerd_socket_baseurl + "/images/json",
+        json=test_search_respoonse,
+    )
+    ldi = LocalDockerdInterface()
+    resp = ldi.get_image_info_from_digest("sha256:test-digest")
+    assert resp is None
+
+    emitter.assert_interactions(None)
+
+
+def test_localdockerinterface_get_info_by_digest_bad_response(responses, emitter):
+    """Docker answered badly when checking for the image."""
+    # weird dockerd behaviour
+    responses.add(
+        responses.GET,
+        LocalDockerdInterface.dockerd_socket_baseurl + "/images/json",
+        status=500,
+    )
+    ldi = LocalDockerdInterface()
+    resp = ldi.get_image_info_from_digest("sha256:test-digest")
+    assert resp is None
+
+    emitter.assert_debug("Bad response when validating local image: 500")
+
+
+def test_localdockerinterface_get_info_by_digest_disconnected(emitter, responses):
+    """No daemon to talk to (see responses used as fixture but no listening)."""
+    ldi = LocalDockerdInterface()
+    resp = ldi.get_image_info_from_digest("sha256:test-digest")
     assert resp is None
 
     emitter.assert_debug(
@@ -807,11 +867,11 @@ def test_localdockerinterface_get_streamed_content(responses):
     test_content = AuditableBufferedReader(io.BytesIO(b"123456789"))
     responses.add(
         responses.GET,
-        LocalDockerdInterface.dockerd_socket_baseurl + "/images/test-digest/get",
+        LocalDockerdInterface.dockerd_socket_baseurl + "/images/test-id/get",
         body=test_content,
     )
     ldi = LocalDockerdInterface()
-    resp = ldi.get_streamed_image_content("test-digest")
+    resp = ldi.get_streamed_image_content("test-id")
     assert test_content._test_read_chunks == []
 
     chunk_size = 5
@@ -851,17 +911,13 @@ class FakeRegistry:
 class FakeDockerd:
     """A fake dockerd interface to mimic behaviour of the real one."""
 
-    def __init__(self, image_info, image_content):
+    def __init__(self, image_id, image_info, image_content):
         self.image_info = image_info
         self.image_content = image_content
-        self.used_digest = None
+        self.used_id = image_id
 
-    def get_image_info(self, digest):
-        self.used_digest = digest
-        return self.image_info.get(digest)
-
-    def get_streamed_image_content(self, digest):
-        assert digest == self.used_digest
+    def get_streamed_image_content(self, image_id):
+        assert image_id == self.used_id
 
         class FakeResponse:
             def __init__(self, content):
@@ -1030,10 +1086,11 @@ def test_imagehandler_uploadfromlocal_complete(emitter, tmp_path, responses, mon
         tar_file.addfile(ti, fileobj=io.BytesIO(content))
     tar_file.close()
 
-    # return 200 with the image info
+    # prepare the image info
     image_size = test_tar_image.stat().st_size
-    image_info = {"Size": image_size, "foobar": "etc"}
-    fakedockerd = FakeDockerd({"test-digest": image_info}, test_tar_image.read_bytes())
+    image_id = "test-image-id"
+    image_info = {"Size": image_size, "Id": image_id, "foobar": "etc"}
+    fakedockerd = FakeDockerd(image_id, image_info, test_tar_image.read_bytes())
     monkeypatch.setattr(registry, "LocalDockerdInterface", lambda: fakedockerd)
 
     # ensure two reads from that image, so we can properly test progress
@@ -1043,7 +1100,7 @@ def test_imagehandler_uploadfromlocal_complete(emitter, tmp_path, responses, mon
 
     fake_registry = FakeRegistry()
     im = ImageHandler(fake_registry)
-    main_call_result = im.upload_from_local("test-digest")
+    main_call_result = im.upload_from_local(image_info)
 
     # check the uploaded blobs: first the config (as is), then the layers (compressed)
     (
@@ -1105,7 +1162,6 @@ def test_imagehandler_uploadfromlocal_complete(emitter, tmp_path, responses, mon
     # check the output logs
     emitter.assert_interactions(
         [
-            call("progress", "Checking image is present locally"),
             call("progress", "Getting the image from the local repo; size={}".format(image_size)),
             call("progress_bar", "Reading image...", image_size),
             call("advance", image_read_from_dockerd_size_1),
@@ -1133,19 +1189,6 @@ def test_imagehandler_uploadfromlocal_complete(emitter, tmp_path, responses, mon
     )
 
 
-def test_imagehandler_uploadfromlocal_not_found_locally(emitter, monkeypatch):
-    """The requested image is not presented locally."""
-    # will not find the image
-    fakedockerd = FakeDockerd({}, b"")
-    monkeypatch.setattr(registry, "LocalDockerdInterface", lambda: fakedockerd)
-
-    im = ImageHandler("a-registry")
-    result = im.upload_from_local("test-digest")
-    assert result is None
-
-    emitter.assert_progress("Checking image is present locally")
-
-
 @pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
 def test_imagehandler_uploadfromlocal_no_config(emitter, tmp_path, monkeypatch):
     """Particular case of a manifest without config."""
@@ -1172,13 +1215,14 @@ def test_imagehandler_uploadfromlocal_no_config(emitter, tmp_path, monkeypatch):
 
     # return 200 with the image info
     image_size = test_tar_image.stat().st_size
-    image_info = {"Size": image_size, "foobar": "etc"}
-    fakedockerd = FakeDockerd({"test-digest": image_info}, test_tar_image.read_bytes())
+    image_id = "test-image-id"
+    image_info = {"Size": image_size, "Id": image_id, "foobar": "etc"}
+    fakedockerd = FakeDockerd(image_id, image_info, test_tar_image.read_bytes())
     monkeypatch.setattr(registry, "LocalDockerdInterface", lambda: fakedockerd)
 
     fake_registry = FakeRegistry()
     im = ImageHandler(fake_registry)
-    main_call_result = im.upload_from_local("test-digest")
+    main_call_result = im.upload_from_local(image_info)
 
     # check the uploaded blob: just the compressed layer
     (uploaded_layer,) = fake_registry.stored_blobs.items()
@@ -1216,7 +1260,6 @@ def test_imagehandler_uploadfromlocal_no_config(emitter, tmp_path, monkeypatch):
     # check the output logs
     emitter.assert_interactions(
         [
-            call("progress", "Checking image is present locally"),
             call("progress", "Getting the image from the local repo; size={}".format(image_size)),
             call("progress_bar", "Reading image...", image_size),
             call("advance", image_size),