Repository: pantsbuild/pants

Issue:
Can't get pants to work with ECR
**Describe the bug**
I am unable to get pants to be able to access docker repos on ECR. We use ECR repos to store the base image of images `package`d with pants as well as the target registry for images we would like to `publish`.

For now, we are forced to manually do `docker pull ...` and `docker push ...` operations before and after using `./pants package` and are unable to use the `publish` goal. This "works" only by having bash logic that can predict the images we need/are produced.

Given a Dockerfile like:
```
FROM 000000000000.dkr.ecr.us-east-1.amazonaws.com/base:label
```

It will fail if we do this:
```
$ aws ecr get-login-password |  docker login --username AWS --password-stdin "000000000000.dkr.ecr.us-east-1.amazonaws.com"
Login Succeeded
$ ./pants package target
...
#3 ERROR: unexpected status code [manifests label]: 401 Unauthorized
```
Whereas it will succeed if we do this instead:
```
$ aws ecr get-login-password |  docker login --username AWS --password-stdin "000000000000.dkr.ecr.us-east-1.amazonaws.com"
Login Succeeded
$ docker pull 000000000000.dkr.ecr.us-east-1.amazonaws.com/base:label
123456789a: Pulling from base-python
Status: Image is up to date for 00000000.dkr.ecr.us-east-1.amazonaws.com/base:label
$ ./pants package target
22:29:31.52 [INFO] Built docker images: 
  * 000000000000.dkr.ecr.us-east-1.amazonaws.com/target:latest
```

**Pants version**
2.9.0

**OS**
Are you encountering the bug on MacOS, Linux, or both?
tested only on linux 


Discussion:

Resolving patch:
diff --git a/src/python/pants/backend/docker/subsystems/docker_options.py b/src/python/pants/backend/docker/subsystems/docker_options.py
--- a/src/python/pants/backend/docker/subsystems/docker_options.py
+++ b/src/python/pants/backend/docker/subsystems/docker_options.py
@@ -164,6 +164,16 @@ class DockerOptions(Subsystem):
         advanced=True,
         metavar="<binary-paths>",
     )
+    _tools = StrListOption(
+        "--tools",
+        default=[],
+        help=(
+            "List any additional executable tools required for Docker to work. The paths to "
+            "these tools will be included in the PATH used in the execution sandbox, so that "
+            "they may be used by the Docker client."
+        ),
+        advanced=True,
+    )
 
     @property
     def build_args(self) -> tuple[str, ...]:
@@ -173,6 +183,10 @@ def build_args(self) -> tuple[str, ...]:
     def env_vars(self) -> tuple[str, ...]:
         return tuple(sorted(set(self._env_vars)))
 
+    @property
+    def tools(self) -> tuple[str, ...]:
+        return tuple(sorted(set(self._tools)))
+
     @memoized_method
     def registries(self) -> DockerRegistries:
         return DockerRegistries.from_dict(self._registries)
diff --git a/src/python/pants/backend/docker/util_rules/docker_binary.py b/src/python/pants/backend/docker/util_rules/docker_binary.py
--- a/src/python/pants/backend/docker/util_rules/docker_binary.py
+++ b/src/python/pants/backend/docker/util_rules/docker_binary.py
@@ -3,18 +3,19 @@
 
 from __future__ import annotations
 
+import os
 from dataclasses import dataclass
 from typing import Mapping
 
 from pants.backend.docker.subsystems.docker_options import DockerOptions
 from pants.backend.docker.util_rules.docker_build_args import DockerBuildArgs
 from pants.core.util_rules.system_binaries import (
-    BinaryNotFoundError,
     BinaryPath,
     BinaryPathRequest,
     BinaryPaths,
     BinaryPathTest,
-    SearchPath,
+    BinaryShims,
+    BinaryShimsRequest,
 )
 from pants.engine.environment import Environment, EnvironmentRequest
 from pants.engine.fs import Digest
@@ -24,10 +25,36 @@
 from pants.util.strutil import pluralize
 
 
+# The base class is decorated with `frozen_after_init`.
+@dataclass
 class DockerBinary(BinaryPath):
     """The `docker` binary."""
 
-    DEFAULT_SEARCH_PATH = SearchPath(("/usr/bin", "/bin", "/usr/local/bin"))
+    extra_env: Mapping[str, str]
+    extra_input_digests: Mapping[str, Digest] | None
+
+    def __init__(
+        self,
+        path: str,
+        fingerprint: str | None = None,
+        extra_env: Mapping[str, str] | None = None,
+        extra_input_digests: Mapping[str, Digest] | None = None,
+    ) -> None:
+        self.extra_env = {} if extra_env is None else extra_env
+        self.extra_input_digests = extra_input_digests
+        super().__init__(path, fingerprint)
+
+    def _get_process_environment(self, env: Mapping[str, str]) -> Mapping[str, str]:
+        if not self.extra_env:
+            return env
+
+        res = {**self.extra_env, **env}
+
+        # Merge the PATH entries, in case they are present in both `env` and `self.extra_env`.
+        res["PATH"] = os.pathsep.join(
+            p for p in (m.get("PATH") for m in (self.extra_env, env)) if p
+        )
+        return res
 
     def build_image(
         self,
@@ -58,8 +85,9 @@ def build_image(
                 f"Building docker image {tags[0]}"
                 + (f" +{pluralize(len(tags)-1, 'additional tag')}." if len(tags) > 1 else "")
             ),
-            env=env,
+            env=self._get_process_environment(env),
             input_digest=digest,
+            immutable_input_digests=self.extra_input_digests,
             cache_scope=ProcessCacheScope.PER_SESSION,
         )
 
@@ -71,7 +99,8 @@ def push_image(
                 argv=(self.path, "push", tag),
                 cache_scope=ProcessCacheScope.PER_SESSION,
                 description=f"Pushing docker image {tag}",
-                env=env,
+                env=self._get_process_environment(env or {}),
+                immutable_input_digests=self.extra_input_digests,
             )
             for tag in tags
         )
@@ -88,16 +117,17 @@ def run_image(
             argv=(self.path, "run", *(docker_run_args or []), tag, *(image_args or [])),
             cache_scope=ProcessCacheScope.PER_SESSION,
             description=f"Running docker image {tag}",
-            env=env,
+            env=self._get_process_environment(env or {}),
+            immutable_input_digests=self.extra_input_digests,
         )
 
 
 @dataclass(frozen=True)
 class DockerBinaryRequest:
-    search_path: SearchPath = DockerBinary.DEFAULT_SEARCH_PATH
+    pass
 
 
-@rule(desc="Finding the `docker` binary", level=LogLevel.DEBUG)
+@rule(desc="Finding the `docker` binary and related tooling", level=LogLevel.DEBUG)
 async def find_docker(
     docker_request: DockerBinaryRequest, docker_options: DockerOptions
 ) -> DockerBinary:
@@ -105,14 +135,35 @@ async def find_docker(
     search_path = docker_options.executable_search_path(env)
     request = BinaryPathRequest(
         binary_name="docker",
-        search_path=search_path or docker_request.search_path,
+        search_path=search_path,
         test=BinaryPathTest(args=["-v"]),
     )
     paths = await Get(BinaryPaths, BinaryPathRequest, request)
-    first_path = paths.first_path
-    if not first_path:
-        raise BinaryNotFoundError.from_request(request, rationale="interact with the docker daemon")
-    return DockerBinary(first_path.path, first_path.fingerprint)
+    first_path = paths.first_path_or_raise(request, rationale="interact with the docker daemon")
+
+    if not docker_options.tools:
+        return DockerBinary(first_path.path, first_path.fingerprint)
+
+    tools = await Get(
+        BinaryShims,
+        BinaryShimsRequest,
+        BinaryShimsRequest.for_binaries(
+            *docker_options.tools,
+            rationale="use docker",
+            output_directory="bin",
+            search_path=search_path,
+        ),
+    )
+    tools_path = ".shims"
+    extra_env = {"PATH": os.path.join(tools_path, tools.bin_directory)}
+    extra_input_digests = {tools_path: tools.digest}
+
+    return DockerBinary(
+        first_path.path,
+        first_path.fingerprint,
+        extra_env=extra_env,
+        extra_input_digests=extra_input_digests,
+    )
 
 
 @rule


Test patch: