Repository: beeware/briefcase

Issue:
Better user feedback when Linux system package target is not available in the Docker registry
### Describe the bug

If a user targets a platform for which a suitable Docker image is not available, the user feedback doesn't really communicate that.

```console
$ briefcase package --target fedora:37
Unable to find image 'fedora:37' locally
37: Pulling from library/fedora
docker: no matching manifest for linux/arm/v7 in the manifest list entries.
See 'docker run --help'.

Unable to determine if Docker is mapping users
```

While the above example is trying to pull an image for an architecture it doesn't provide, a simple misspelling creates the same situation.

```console
❯ briefcase package --target ubnutu
Unable to find image 'ubnutu:latest' locally
docker: Error response from daemon: pull access denied for ubnutu, repository does not exist or may require 'docker login': denied: requested access to the resource is denied.
See 'docker run --help'.

Unable to determine if Docker is mapping users
```

### Steps to reproduce

Run `briefcase package --target ubnutu`

### Expected behavior

I know the output explicitly says "unable to find image" but the "Unable to determine if Docker is mapping users" is in red and at least causes initial, if not entire, misdirection to the real problem.

Docker has different exit codes for when `docker run` fails versus when something _inside_ a container goes wrong.

Therefore, I think we can catch and interpret these [exit codes](https://docs.docker.com/engine/reference/run/#exit-status) differently:
```
    125: docker run itself fails
    126: contained command cannot be invoked
    127: if contained command cannot be found
    128 + n Fatal error signal n:
        130 = (128+2) Container terminated by Control-C
        137 = (128+9) Container received a SIGKILL
        143 = (128+15) Container received a SIGTERM
```

In this way, we could detect `125` for any `docker run` call and output a more generic message about the target image.

### Screenshots

_No response_

### Environment

- Operating System: pop os 22.04
- Python version: 3.10.12
- Software versions:
  - Briefcase: `0.3.16.dev19+g30ee4a80`

### Logs

[briefcase.2023_07_21-11_31_11.package.log](https://github.com/beeware/briefcase/files/12130279/briefcase.2023_07_21-11_31_11.package.log)


### Additional context

_No response_

Discussion:

Patch:
diff --git a/changes/1368.bugfix.rst b/changes/1368.bugfix.rst
new file mode 100644
index 00000000..f8f95413
--- /dev/null
+++ b/changes/1368.bugfix.rst
@@ -0,0 +1,1 @@
+Error reporting has been improved when the target Docker image name is invalid.
diff --git a/src/briefcase/integrations/docker.py b/src/briefcase/integrations/docker.py
index d1b69538..6b7e773c 100644
--- a/src/briefcase/integrations/docker.py
+++ b/src/briefcase/integrations/docker.py
@@ -271,13 +271,18 @@ See https://docs.docker.com/go/buildx/ to install the buildx plugin.
             "/host_write_test", host_write_test_path.name
         )
 
+        image_tag = "alpine" if image_tag is None else image_tag
+        # Cache the image first so the attempts below to run the image don't
+        # log irrelevant errors when the image may just have a simple typo
+        self.cache_image(image_tag)
+
         docker_run_cmd = [
             "docker",
             "run",
             "--rm",
             "--volume",
             f"{host_write_test_path.parent}:{container_write_test_path.parent}:z",
-            "alpine" if image_tag is None else image_tag,
+            image_tag,
         ]
 
         host_write_test_path.parent.mkdir(exist_ok=True)
@@ -300,7 +305,6 @@ Delete this file and run Briefcase again.
             self.tools.subprocess.run(
                 docker_run_cmd + ["touch", container_write_test_path],
                 check=True,
-                stream_output=False,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(
@@ -314,7 +318,6 @@ Delete this file and run Briefcase again.
             self.tools.subprocess.run(
                 docker_run_cmd + ["rm", "-f", container_write_test_path],
                 check=True,
-                stream_output=False,
             )
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(
@@ -341,8 +344,17 @@ Delete this file and run Briefcase again.
         ).strip()
 
         if not image_id:
+            self.tools.logger.info(
+                f"Downloading Docker base image for {image_tag}...",
+                prefix="Docker",
+            )
             try:
-                self.tools.subprocess.run(["docker", "pull", image_tag], check=True)
+                # disable streaming so image download progress bar is shown
+                self.tools.subprocess.run(
+                    ["docker", "pull", image_tag],
+                    check=True,
+                    stream_output=False,
+                )
             except subprocess.CalledProcessError as e:
                 raise BriefcaseCommandError(
                     f"Unable to obtain the Docker image for {image_tag}. "
@@ -366,13 +378,7 @@ Delete this file and run Briefcase again.
         # This ensures that "docker.check_output()" behaves as closely to
         # "subprocess.check_output()" as possible.
         return self.tools.subprocess.check_output(
-            [
-                "docker",
-                "run",
-                "--rm",
-                image_tag,
-            ]
-            + args,
+            ["docker", "run", "--rm", image_tag] + args
         )
 
 


Test patch:
diff --git a/tests/integrations/docker/conftest.py b/tests/integrations/docker/conftest.py
index 5d2e8ce1..47b92ebe 100644
--- a/tests/integrations/docker/conftest.py
+++ b/tests/integrations/docker/conftest.py
@@ -91,7 +91,6 @@ def user_mapping_run_calls(tmp_path, monkeypatch) -> list:
                 PurePosixPath("/host_write_test/mock_write_test"),
             ],
             check=True,
-            stream_output=False,
         ),
         call(
             [
@@ -106,6 +105,5 @@ def user_mapping_run_calls(tmp_path, monkeypatch) -> list:
                 PurePosixPath("/host_write_test/mock_write_test"),
             ],
             check=True,
-            stream_output=False,
         ),
     ]
diff --git a/tests/integrations/docker/test_DockerAppContext__verify.py b/tests/integrations/docker/test_DockerAppContext__verify.py
index fc8f1087..62229095 100644
--- a/tests/integrations/docker/test_DockerAppContext__verify.py
+++ b/tests/integrations/docker/test_DockerAppContext__verify.py
@@ -50,6 +50,7 @@ def test_success(mock_tools, first_app_config, verify_kwargs):
         "Docker version 19.03.8, build afacb8b\n",
         "docker info return value",
         "github.com/docker/buildx v0.10.2 00ed17d\n",
+        "1ed313b0551f",  # cached image check
     ]
 
     DockerAppContext.verify(mock_tools, first_app_config, **verify_kwargs)
@@ -97,6 +98,7 @@ def test_docker_image_build_fail(mock_tools, first_app_config, verify_kwargs):
         "Docker version 19.03.8, build afacb8b\n",
         "docker info return value",
         "github.com/docker/buildx v0.10.2 00ed17d\n",
+        "1ed313b0551f",  # cached image check
     ]
 
     mock_tools.subprocess.run.side_effect = [
diff --git a/tests/integrations/docker/test_Docker__cache_image.py b/tests/integrations/docker/test_Docker__cache_image.py
index 44f62f4c..957467e0 100644
--- a/tests/integrations/docker/test_Docker__cache_image.py
+++ b/tests/integrations/docker/test_Docker__cache_image.py
@@ -25,13 +25,14 @@ def test_cache_image(mock_tools, user_mapping_run_calls):
     mock_tools.docker.cache_image("ubuntu:jammy")
 
     # Confirms that image is not available
-    mock_tools.subprocess.check_output.assert_called_once_with(
+    mock_tools.subprocess.check_output.assert_called_with(
         ["docker", "images", "-q", "ubuntu:jammy"]
     )
 
     # Image is pulled and cached
     mock_tools.subprocess.run.assert_has_calls(
-        user_mapping_run_calls + [call(["docker", "pull", "ubuntu:jammy"], check=True)]
+        user_mapping_run_calls
+        + [call(["docker", "pull", "ubuntu:jammy"], check=True, stream_output=False)]
     )
 
 
@@ -44,7 +45,7 @@ def test_cache_image_already_cached(mock_tools, user_mapping_run_calls):
     mock_tools.docker.cache_image("ubuntu:jammy")
 
     # Confirms that image is not available
-    mock_tools.subprocess.check_output.assert_called_once_with(
+    mock_tools.subprocess.check_output.assert_called_with(
         ["docker", "images", "-q", "ubuntu:jammy"]
     )
 
diff --git a/tests/integrations/docker/test_Docker__verify.py b/tests/integrations/docker/test_Docker__verify.py
index 1f1082ff..969d37a6 100644
--- a/tests/integrations/docker/test_Docker__verify.py
+++ b/tests/integrations/docker/test_Docker__verify.py
@@ -13,6 +13,7 @@ from briefcase.integrations.subprocess import Subprocess
 VALID_DOCKER_VERSION = "Docker version 19.03.8, build afacb8b\n"
 VALID_DOCKER_INFO = "docker info printout"
 VALID_BUILDX_VERSION = "github.com/docker/buildx v0.10.2 00ed17d\n"
+VALID_USER_MAPPING_IMAGE_CACHE = "1ed313b0551f"
 DOCKER_VERIFICATION_CALLS = [
     call(["docker", "--version"]),
     call(["docker", "info"]),
@@ -70,6 +71,7 @@ def test_docker_exists(mock_tools, user_mapping_run_calls, capsys, tmp_path):
         VALID_DOCKER_VERSION,
         VALID_DOCKER_INFO,
         VALID_BUILDX_VERSION,
+        VALID_USER_MAPPING_IMAGE_CACHE,
     ]
 
     # Invoke docker verify
@@ -114,6 +116,7 @@ def test_docker_failure(mock_tools, user_mapping_run_calls, capsys):
         ),
         "Success!",
         VALID_BUILDX_VERSION,
+        VALID_USER_MAPPING_IMAGE_CACHE,
     ]
 
     # Invoke Docker verify
@@ -280,6 +283,7 @@ def test_docker_image_hint(mock_tools):
         VALID_DOCKER_VERSION,
         VALID_DOCKER_INFO,
         VALID_BUILDX_VERSION,
+        VALID_USER_MAPPING_IMAGE_CACHE,
     ]
 
     Docker.verify(mock_tools, image_tag="myimage:tagtorulethemall")
@@ -298,7 +302,6 @@ def test_docker_image_hint(mock_tools):
                     PurePosixPath("/host_write_test/container_write_test"),
                 ],
                 check=True,
-                stream_output=False,
             ),
             call(
                 [
@@ -313,7 +316,6 @@ def test_docker_image_hint(mock_tools):
                     PurePosixPath("/host_write_test/container_write_test"),
                 ],
                 check=True,
-                stream_output=False,
             ),
         ]
     )
@@ -333,6 +335,7 @@ def test_user_mapping_write_file_exists(mock_tools, mock_write_test_path):
         VALID_DOCKER_VERSION,
         VALID_DOCKER_INFO,
         VALID_BUILDX_VERSION,
+        VALID_USER_MAPPING_IMAGE_CACHE,
     ]
 
     # Mock failure for deleting an existing write test file
@@ -353,6 +356,7 @@ def test_user_mapping_write_test_file_creation_fails(mock_tools, mock_write_test
         VALID_DOCKER_VERSION,
         VALID_DOCKER_INFO,
         VALID_BUILDX_VERSION,
+        VALID_USER_MAPPING_IMAGE_CACHE,
     ]
 
     # Mock failure for deleting an existing write test file
@@ -376,6 +380,7 @@ def test_user_mapping_write_test_file_cleanup_fails(mock_tools, mock_write_test_
         VALID_DOCKER_VERSION,
         VALID_DOCKER_INFO,
         VALID_BUILDX_VERSION,
+        VALID_USER_MAPPING_IMAGE_CACHE,
     ]
 
     # Mock failure for deleting an existing write test file
@@ -406,6 +411,7 @@ def test_user_mapping_setting(
         VALID_DOCKER_VERSION,
         VALID_DOCKER_INFO,
         VALID_BUILDX_VERSION,
+        VALID_USER_MAPPING_IMAGE_CACHE,
     ]
 
     stat_result = namedtuple("stat_result", "st_uid")