Repository: pantsbuild/pants

Issue:
Misleading error message when unknown docker build-arg is referenced in `image_tags`
**Describe the bug**
Given a target like:
```
docker_image(
    name="color-base",
    image_tags=["{build_args.DOCKER_VERSION}"]
)
```
If you run:
```
./pants package path/to/Dockerfile
```
You'll get an error like:
```
The placeholder 'build_args' is unknown. Try with one of: baseimage, stage0.
```

This is a misleading message because `build_args` _is_ known to pants, but only if some args are specified in config / over the CLI. For example, this works:
```
./pants --docker-build-args="['DOCKER_VERSION=1.0.0']" package path/to/Dockerfile
```

The error message would be more helpful if it instead called out when a build-arg is undefined. Ideally something like:
```
The build arg 'DOCKER_VERSION' is undefined. Set the value using '--docker-build-args' or in 'pants.toml'.
```

**Pants version**
2.8.0

**OS**
MacOS


Discussion:
Related #13723. 

Resolving patch:
diff --git a/src/python/pants/backend/docker/target_types.py b/src/python/pants/backend/docker/target_types.py
--- a/src/python/pants/backend/docker/target_types.py
+++ b/src/python/pants/backend/docker/target_types.py
@@ -170,7 +170,7 @@ class DockerBuildSecretsOptionField(
 ):
     alias = "secrets"
     help = (
-        "Secret file to expose to the build (only if BuildKit enabled).\n\n"
+        "Secret files to expose to the build (only if BuildKit enabled).\n\n"
         "Secrets may use absolute paths, or paths relative to your BUILD file. The id should be "
         "valid as used by the Docker build `--secret` option. "
         "See [Docker secrets](https://docs.docker.com/engine/swarm/secrets/) for more "
@@ -181,7 +181,8 @@ class DockerBuildSecretsOptionField(
 
                 docker_image(
                     secrets={
-                        "mysecret": "/local/secret",
+                        "mysecret": "/var/secrets/some-secret",
+                        "repo-secret": "proj/path/some-secret",
                     }
                 )
             """
diff --git a/src/python/pants/backend/docker/util_rules/docker_build_context.py b/src/python/pants/backend/docker/util_rules/docker_build_context.py
--- a/src/python/pants/backend/docker/util_rules/docker_build_context.py
+++ b/src/python/pants/backend/docker/util_rules/docker_build_context.py
@@ -6,7 +6,7 @@
 import logging
 from abc import ABC
 from dataclasses import dataclass
-from typing import Mapping
+from typing import ClassVar, Mapping
 
 from pants.backend.docker.subsystems.docker_options import DockerOptions
 from pants.backend.docker.subsystems.dockerfile_parser import DockerfileInfo, DockerfileInfoRequest
@@ -50,27 +50,67 @@ class DockerBuildContextError(Exception):
 
 
 class DockerVersionContextError(ValueError):
-    pass
+    @classmethod
+    def attribute_error(
+        cls, context: DockerVersionContextValue, attribute: str
+    ) -> DockerVersionContextError:
+        msg = f"The placeholder {attribute!r} is unknown."
+        if context:
+            msg += f' Try with one of: {", ".join(context.keys())}.'
+        return cls(msg)
 
 
 class DockerVersionContextValue(FrozenDict[str, str]):
     """Dict class suitable for use as a format string context object, as it allows to use attribute
     access rather than item access."""
 
+    _attribute_error_type: ClassVar[type[DockerVersionContextError]] = DockerVersionContextError
+
+    @classmethod
+    def create(
+        cls, value: Mapping[str, str] | DockerVersionContextValue
+    ) -> DockerVersionContextValue:
+        """Create new instance of `DockerVersionContextValue` unless `value` already is an instance
+        (or subclass) of `DockerVersionContextValue`, in which case `value` is returned as-is."""
+        if isinstance(value, cls):
+            return value
+        return cls(value)
+
     def __getattr__(self, attribute: str) -> str:
         if attribute not in self:
-            raise DockerVersionContextError(
-                f"The placeholder {attribute!r} is unknown. Try with one of: "
-                f'{", ".join(self.keys())}.'
-            )
+            raise self._attribute_error_type.attribute_error(self, attribute)
         return self[attribute]
 
 
+class DockerVersionContextBuildArgError(DockerVersionContextError):
+    @classmethod
+    def attribute_error(
+        cls, context: DockerVersionContextValue, attribute: str
+    ) -> DockerVersionContextError:
+        msg = f"The build arg {attribute!r} is undefined."
+        if context:
+            msg += f' Defined build args are: {", ".join(context.keys())}.'
+        msg += (
+            "\n\nThis build arg may be defined in `pants.toml` under `[docker].build_args`, on the "
+            "command line with `--docker-build-args` or directly on the `docker_image` target "
+            "using the `extra_build_args` field."
+        )
+        return cls(msg)
+
+
+class DockerVersionContextBuildArgsValue(DockerVersionContextValue):
+    """Version context value with specific error handling for build args."""
+
+    _attribute_error_type = DockerVersionContextBuildArgError
+
+
 class DockerVersionContext(FrozenDict[str, DockerVersionContextValue]):
     @classmethod
-    def from_dict(cls, data: Mapping[str, Mapping[str, str]]) -> DockerVersionContext:
+    def from_dict(
+        cls, data: Mapping[str, Mapping[str, str] | DockerVersionContextValue]
+    ) -> DockerVersionContext:
         return DockerVersionContext(
-            {key: DockerVersionContextValue(value) for key, value in data.items()}
+            {key: DockerVersionContextValue.create(value) for key, value in data.items()}
         )
 
     def merge(self, other: Mapping[str, Mapping[str, str]]) -> DockerVersionContext:
@@ -134,7 +174,7 @@ def create(
         build_env: DockerBuildEnvironment,
         dockerfile_info: DockerfileInfo,
     ) -> DockerBuildContext:
-        version_context: dict[str, dict[str, str]] = {}
+        version_context: dict[str, dict[str, str] | DockerVersionContextValue] = {}
 
         # FROM tags for all stages.
         for stage, tag in [tag.split(maxsplit=1) for tag in dockerfile_info.version_tags]:
@@ -154,6 +194,12 @@ def create(
                 if has_default
             }
             try:
+                # Create build args context value, based on defined build_args and
+                # extra_build_args. We do _not_ auto "magically" pick up all ARG names from the
+                # Dockerfile as first class args to use as placeholders, to make it more explicit
+                # which args are actually being used by Pants. We do pick up any defined default ARG
+                # values from the Dockerfile however, in order to not having to duplicate them in
+                # the BUILD files.
                 version_context["build_args"] = {
                     arg_name: arg_value
                     if has_value
@@ -172,6 +218,11 @@ def create(
                     "instruction in the `Dockerfile`."
                 ) from e
 
+        # Override default value type for the `build_args` context to get helpful error messages.
+        version_context["build_args"] = DockerVersionContextBuildArgsValue(
+            version_context.get("build_args", {})
+        )
+
         return cls(
             build_args=build_args,
             digest=digest,


Test patch:
diff --git a/src/python/pants/backend/docker/goals/package_image_test.py b/src/python/pants/backend/docker/goals/package_image_test.py
--- a/src/python/pants/backend/docker/goals/package_image_test.py
+++ b/src/python/pants/backend/docker/goals/package_image_test.py
@@ -217,7 +217,7 @@ def test_build_docker_image(rule_runner: RuleRunner) -> None:
     err1 = (
         r"Invalid value for the `repository` field of the `docker_image` target at "
         r"docker/test:err1: '{bad_template}'\.\n\nThe placeholder 'bad_template' is unknown\. "
-        r"Try with one of: directory, name, parent_directory\."
+        r"Try with one of: build_args, directory, name, parent_directory\."
     )
     with pytest.raises(DockerRepositoryNameError, match=err1):
         assert_build(
diff --git a/src/python/pants/backend/docker/util_rules/docker_build_context_test.py b/src/python/pants/backend/docker/util_rules/docker_build_context_test.py
--- a/src/python/pants/backend/docker/util_rules/docker_build_context_test.py
+++ b/src/python/pants/backend/docker/util_rules/docker_build_context_test.py
@@ -36,8 +36,7 @@
 from pants.testutil.rule_runner import QueryRule, RuleRunner
 
 
-@pytest.fixture
-def rule_runner() -> RuleRunner:
+def create_rule_runner() -> RuleRunner:
     rule_runner = RuleRunner(
         rules=[
             *context_rules(),
@@ -64,6 +63,11 @@ def rule_runner() -> RuleRunner:
     return rule_runner
 
 
+@pytest.fixture
+def rule_runner() -> RuleRunner:
+    return create_rule_runner()
+
+
 def assert_build_context(
     rule_runner: RuleRunner,
     address: Address,
@@ -72,7 +76,7 @@ def assert_build_context(
     expected_version_context: dict[str, dict[str, str]] | None = None,
     pants_args: list[str] | None = None,
     runner_options: dict[str, Any] | None = None,
-) -> None:
+) -> DockerBuildContext:
     if runner_options is None:
         runner_options = {}
     runner_options.setdefault("env_inherit", set()).update({"PATH", "PYENV_ROOT", "HOME"})
@@ -91,6 +95,7 @@ def assert_build_context(
     assert sorted(expected_files) == sorted(snapshot.files)
     if expected_version_context is not None:
         assert context.version_context == DockerVersionContext.from_dict(expected_version_context)
+    return context
 
 
 def test_file_dependencies(rule_runner: RuleRunner) -> None:
@@ -232,6 +237,7 @@ def test_version_context_from_dockerfile(rule_runner: RuleRunner) -> None:
             "interim": {"tag": "latest"},
             "stage2": {"tag": "latest"},
             "output": {"tag": "1-1"},
+            "build_args": {},
         },
     )
 
@@ -264,6 +270,7 @@ def test_synthetic_dockerfile(rule_runner: RuleRunner) -> None:
             "interim": {"tag": "latest"},
             "stage2": {"tag": "latest"},
             "output": {"tag": "1-1"},
+            "build_args": {},
         },
     )
 
@@ -420,3 +427,60 @@ def test_undefined_env_var_behavior(
             Address("src/docker"),
             expected_files=["src/docker/Dockerfile"],
         )
+
+
+@pytest.fixture(scope="session")
+def build_context() -> DockerBuildContext:
+    rule_runner = create_rule_runner()
+    rule_runner.write_files(
+        {
+            "src/docker/BUILD": dedent(
+                """\
+                docker_image(
+                  extra_build_args=["DEF_ARG"],
+                  instructions=[
+                    "FROM python:3.8",
+                    "ARG MY_ARG",
+                    "ARG DEF_ARG=some-value",
+                  ],
+                )
+                """
+            ),
+        }
+    )
+
+    return assert_build_context(
+        rule_runner,
+        Address("src/docker"),
+        expected_files=["src/docker/Dockerfile.docker"],
+    )
+
+
+@pytest.mark.parametrize(
+    "fmt_string, result, expectation",
+    [
+        pytest.param(
+            "{build_args.MY_ARG}",
+            None,
+            pytest.raises(
+                ValueError,
+                match=(r"The build arg 'MY_ARG' is undefined\. Defined build args are: DEF_ARG\."),
+            ),
+            id="ARG_NAME",
+        ),
+        pytest.param(
+            "{build_args.DEF_ARG}",
+            "some-value",
+            no_exception(),
+            id="DEF_ARG",
+        ),
+    ],
+)
+def test_build_arg_behavior(
+    build_context: DockerBuildContext,
+    fmt_string: str,
+    result: str | None,
+    expectation: ContextManager,
+) -> None:
+    with expectation:
+        assert fmt_string.format(**build_context.version_context) == result