Repository: pantsbuild/pants

Issue:
Able to accept docker arguments on `run` goal
**Is your feature request related to a problem? Please describe.**

I'd like to run `./pants run src/docker/backend:docker`, and be able to use [`docker run`](https://docs.docker.com/engine/reference/run/) arguments.

**Describe the solution you'd like**

Something like `./pants run --args="-p 8000:80" src/docker/backend:docker` looks cool.

**Additional context**

Check slack thread: https://pantsbuild.slack.com/archives/C046T6T9U/p1638388489418100


Discussion:
Thanks!

With the docker scope on it, that would be:

```
./pants run --docker-run-args="-p 8000:80" ...
```

Which looks OK to me. However, I'm slightly concerned with that we already have `--docker-build-args='["arg1", "arg2"]'`, which is _not_ passthrough arguments to `docker build <args>`, but rather `docker build --build-arg=<arg1> --build-arg=<arg2> ...`

So, the asymmetry feels wrong to me.. but perhaps that arguments for `docker build` should only come from options provided from configuration/field values on the targets, and build the args dynamically that way. In which case this asymmetry doesn't really matter (if we don't want/need to support arbitrary args for docker build).


Resolving patch:
diff --git a/src/python/pants/backend/docker/goals/run_image.py b/src/python/pants/backend/docker/goals/run_image.py
--- a/src/python/pants/backend/docker/goals/run_image.py
+++ b/src/python/pants/backend/docker/goals/run_image.py
@@ -1,34 +1,31 @@
 # Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
 # Licensed under the Apache License, Version 2.0 (see LICENSE).
+
 from __future__ import annotations
 
-import sys
 from typing import cast
 
 from pants.backend.docker.goals.package_image import BuiltDockerImage, DockerFieldSet
+from pants.backend.docker.subsystems.docker_options import DockerOptions
 from pants.backend.docker.util_rules.docker_binary import DockerBinary
 from pants.core.goals.package import BuiltPackage, PackageFieldSet
 from pants.core.goals.run import RunRequest
-from pants.engine.rules import Get, collect_rules, rule
+from pants.engine.environment import Environment, EnvironmentRequest
+from pants.engine.rules import Get, MultiGet, collect_rules, rule
 
 
 @rule
-async def docker_image_run_request(field_set: DockerFieldSet, docker: DockerBinary) -> RunRequest:
-    image = await Get(BuiltPackage, PackageFieldSet, field_set)
-    return RunRequest(
-        digest=image.digest,
-        args=tuple(
-            cast(str, arg)
-            for arg in (
-                docker.path,
-                "run",
-                "-it" if sys.stdout.isatty() else False,
-                "--rm",
-                cast(BuiltDockerImage, image.artifacts[0]).tags[0],
-            )
-            if arg
-        ),
+async def docker_image_run_request(
+    field_set: DockerFieldSet, docker: DockerBinary, options: DockerOptions
+) -> RunRequest:
+    env, image = await MultiGet(
+        Get(Environment, EnvironmentRequest(options.env_vars)),
+        Get(BuiltPackage, PackageFieldSet, field_set),
     )
+    tag = cast(BuiltDockerImage, image.artifacts[0]).tags[0]
+    run = docker.run_image(tag, docker_run_args=options.run_args, env=env)
+
+    return RunRequest(args=run.argv, digest=image.digest, extra_env=run.env)
 
 
 def rules():
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
@@ -3,14 +3,22 @@
 
 from __future__ import annotations
 
+import sys
 from textwrap import dedent
 from typing import cast
 
 from pants.backend.docker.registries import DockerRegistries
+from pants.option.custom_types import shell_str
 from pants.option.subsystem import Subsystem
 from pants.util.memo import memoized_method
 from pants.util.strutil import bullet_list
 
+doc_links = {
+    "docker_env_vars": (
+        "https://docs.docker.com/engine/reference/commandline/cli/#environment-variables"
+    ),
+}
+
 
 class DockerOptions(Subsystem):
     options_scope = "docker"
@@ -67,35 +75,67 @@ def register_options(cls, register):
         register(
             "--build-args",
             type=list,
-            member_type=str,
+            member_type=shell_str,
             default=[],
             help=(
-                "Global build arguments (`--build-arg`) to use for all `docker build` invocations. "
+                "Global build arguments (for Docker `--build-arg` options) to use for all "
+                "`docker build` invocations.\n\n"
                 "Entries are either strings in the form `ARG_NAME=value` to set an explicit value; "
                 "or just `ARG_NAME` to copy the value from Pants's own environment.\n\n"
-                "May be provided multiple times on the command line.\n\n"
-                "Use the `extra_build_args` field on a `docker_image` target for additional image "
-                "specific build arguments."
+                + dedent(
+                    f"""\
+                    Example:
+
+                        [{cls.options_scope}]
+                        build_args = ["VAR1=value", "VAR2"]
+
+                    """
+                )
+                + "Use the `extra_build_args` field on a `docker_image` target for additional "
+                "image specific build arguments."
             ),
         )
 
         register(
             "--env-vars",
             type=list,
-            member_type=str,
+            member_type=shell_str,
             default=[],
             advanced=True,
             help=(
-                "Environment variables to set for `docker` invocations. "
+                "Environment variables to set for `docker` invocations.\n\n"
                 "Entries are either strings in the form `ENV_VAR=value` to set an explicit value; "
                 "or just `ENV_VAR` to copy the value from Pants's own environment."
             ),
         )
 
+        register(
+            "--run-args",
+            type=list,
+            member_type=shell_str,
+            default=["--interactive", "--tty"] if sys.stdout.isatty() else [],
+            help=(
+                "Additional arguments to use for `docker run` invocations.\n\n"
+                "Example:\n\n"
+                f'    $ ./pants run --{cls.options_scope}-run-args="-p 127.0.0.1:80:8080/tcp '
+                '--name demo" src/example:image -- [image entrypoint args]\n\n'
+                "To provide the top-level options to the `docker` client, use "
+                f"`[{cls.options_scope}].env_vars` to configure the [Environment variables]("
+                f"{doc_links['docker_env_vars']}) as appropriate.\n\n"
+                "The arguments for the image entrypoint may be passed on the command line after a "
+                "double dash (`--`), or using the `--run-args` option.\n\n"
+                "Defaults to `--interactive --tty` when stdout is connected to a terminal."
+            ),
+        )
+
     @property
     def build_args(self) -> tuple[str, ...]:
         return tuple(sorted(set(self.options.build_args)))
 
+    @property
+    def run_args(self) -> tuple[str, ...]:
+        return tuple(self.options.run_args)
+
     @property
     def env_vars(self) -> tuple[str, ...]:
         return tuple(sorted(set(self.options.env_vars)))
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
@@ -75,6 +75,21 @@ def push_image(
             for tag in tags
         )
 
+    def run_image(
+        self,
+        tag: str,
+        *,
+        docker_run_args: tuple[str, ...] | None = None,
+        image_args: tuple[str, ...] | None = None,
+        env: Mapping[str, str] | None = None,
+    ) -> Process:
+        return Process(
+            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,
+        )
+
 
 @dataclass(frozen=True)
 class DockerBinaryRequest:


Test patch:
diff --git a/src/python/pants/backend/docker/util_rules/docker_binary_test.py b/src/python/pants/backend/docker/util_rules/docker_binary_test.py
--- a/src/python/pants/backend/docker/util_rules/docker_binary_test.py
+++ b/src/python/pants/backend/docker/util_rules/docker_binary_test.py
@@ -51,3 +51,17 @@ def test_docker_binary_push_image(docker_path: str, docker: DockerBinary) -> Non
         ),
     )
     assert push_request[0].description == f"Pushing docker image {image_ref}"
+
+
+def test_docker_binary_run_image(docker_path: str, docker: DockerBinary) -> None:
+    image_ref = "registry/repo/name:tag"
+    port_spec = "127.0.0.1:80:8080/tcp"
+    run_request = docker.run_image(
+        image_ref, docker_run_args=("-p", port_spec), image_args=("test-input",)
+    )
+    assert run_request == Process(
+        argv=(docker_path, "run", "-p", port_spec, image_ref, "test-input"),
+        cache_scope=ProcessCacheScope.PER_SESSION,
+        description="",  # The description field is marked `compare=False`
+    )
+    assert run_request.description == f"Running docker image {image_ref}"