Repository: pantsbuild/pants

Issue:
Support customizing the Docker image tags when building images.
**Is your feature request related to a problem? Please describe.**
From a user on Slack:
> Hey folks, we're currently building some docker images with pants, using them to contain some pex binaries. Everything is working as expected, but I've run into a bit of a snag when it comes to tagging. We want to tag the images with the same version as we are using for the pex. We obtain these versions from files kept alongside the source (in a format readable by Hatch). I'd like to be able to read these version files per-target during build and use the version in there as an image tag. I saw in the documentation that anything beyond using ENV values for tagging would likely require a plugin and I've been trying to determine what sort of rule I would need to create to hook into this tagging process.

**Describe the solution you'd like**
Plugin hook to customize the image tags.

**Describe alternatives you've considered**
Subclassing the `docker_image` target and relevant other types in order to hijack the build graph for docker images..




Discussion:

Resolving patch:
diff --git a/src/python/pants/backend/docker/goals/package_image.py b/src/python/pants/backend/docker/goals/package_image.py
--- a/src/python/pants/backend/docker/goals/package_image.py
+++ b/src/python/pants/backend/docker/goals/package_image.py
@@ -7,6 +7,7 @@
 import re
 from dataclasses import dataclass
 from functools import partial
+from itertools import chain
 from typing import Iterator, cast
 
 # Re-exporting BuiltDockerImage here, as it has its natural home here, but has moved out to resolve
@@ -20,7 +21,9 @@
     DockerImageRegistriesField,
     DockerImageRepositoryField,
     DockerImageSourceField,
+    DockerImageTags,
     DockerImageTagsField,
+    DockerImageTagsRequest,
     DockerImageTargetStageField,
 )
 from pants.backend.docker.util_rules.docker_binary import DockerBinary
@@ -39,7 +42,7 @@
 from pants.engine.process import FallibleProcessResult, Process, ProcessExecutionFailure
 from pants.engine.rules import Get, MultiGet, collect_rules, rule
 from pants.engine.target import Target, WrappedTarget, WrappedTargetRequest
-from pants.engine.unions import UnionRule
+from pants.engine.unions import UnionMembership, UnionRule
 from pants.option.global_options import GlobalOptions, KeepSandboxes
 from pants.util.strutil import bullet_list
 
@@ -130,6 +133,7 @@ def image_refs(
         default_repository: str,
         registries: DockerRegistries,
         interpolation_context: DockerInterpolationContext,
+        additional_tags: tuple[str, ...] = (),
     ) -> tuple[str, ...]:
         """The image refs are the full image name, including any registry and version tag.
 
@@ -148,7 +152,7 @@ def image_refs(
 
         This method will always return a non-empty tuple.
         """
-        image_tags = self.tags.value or ()
+        image_tags = (self.tags.value or ()) + additional_tags
         registries_options = tuple(registries.get(*(self.registries.value or [])))
         if not registries_options:
             # The image name is also valid as image ref without registry.
@@ -234,6 +238,7 @@ async def build_docker_image(
     global_options: GlobalOptions,
     docker: DockerBinary,
     keep_sandboxes: KeepSandboxes,
+    union_membership: UnionMembership,
 ) -> BuiltPackage:
     """Build a Docker image using `docker build`."""
     context, wrapped_target = await MultiGet(
@@ -250,10 +255,18 @@ async def build_docker_image(
         ),
     )
 
+    image_tags_requests = union_membership.get(DockerImageTagsRequest)
+    additional_image_tags = await MultiGet(
+        Get(DockerImageTags, DockerImageTagsRequest, image_tags_request_cls(wrapped_target.target))
+        for image_tags_request_cls in image_tags_requests
+        if image_tags_request_cls.is_applicable(wrapped_target.target)
+    )
+
     tags = field_set.image_refs(
         default_repository=options.default_repository,
         registries=options.registries(),
         interpolation_context=context.interpolation_context,
+        additional_tags=tuple(chain.from_iterable(additional_image_tags)),
     )
 
     # Mix the upstream image ids into the env to ensure that Pants invalidates this
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
@@ -6,6 +6,7 @@
 import os
 import re
 from abc import ABC, abstractmethod
+from dataclasses import dataclass
 from typing import Callable, ClassVar, Iterator, Optional, cast
 
 from typing_extensions import final
@@ -14,6 +15,7 @@
 from pants.base.build_environment import get_buildroot
 from pants.core.goals.run import RestartableField
 from pants.engine.addresses import Address
+from pants.engine.collection import Collection
 from pants.engine.fs import GlobMatchErrorBehavior
 from pants.engine.rules import collect_rules, rule
 from pants.engine.target import (
@@ -30,6 +32,7 @@
     Target,
     Targets,
 )
+from pants.engine.unions import union
 from pants.util.docutil import bin_name, doc_url
 from pants.util.strutil import softwrap
 
@@ -374,6 +377,23 @@ class DockerImageTarget(Target):
     )
 
 
+@union
+@dataclass(frozen=True)
+class DockerImageTagsRequest:
+    """A request to provide additional image tags."""
+
+    target: Target
+
+    @classmethod
+    def is_applicable(cls, target: Target) -> bool:
+        """Whether to provide additional tags for this target or not."""
+        return True
+
+
+class DockerImageTags(Collection[str]):
+    """Additional image tags to apply to built Docker images."""
+
+
 class AllDockerImageTargets(Targets):
     pass
 


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
@@ -23,7 +23,11 @@
 from pants.backend.docker.registries import DockerRegistries
 from pants.backend.docker.subsystems.docker_options import DockerOptions
 from pants.backend.docker.subsystems.dockerfile_parser import DockerfileInfo
-from pants.backend.docker.target_types import DockerImageTarget
+from pants.backend.docker.target_types import (
+    DockerImageTags,
+    DockerImageTagsRequest,
+    DockerImageTarget,
+)
 from pants.backend.docker.util_rules.docker_binary import DockerBinary
 from pants.backend.docker.util_rules.docker_build_args import (
     DockerBuildArgs,
@@ -53,6 +57,7 @@
     ProcessResultMetadata,
 )
 from pants.engine.target import InvalidFieldException, WrappedTarget, WrappedTargetRequest
+from pants.engine.unions import UnionMembership, UnionRule
 from pants.option.global_options import GlobalOptions, KeepSandboxes
 from pants.testutil.option_util import create_subsystem
 from pants.testutil.pytest_util import assert_logged, no_exception
@@ -76,6 +81,10 @@ def rule_runner() -> RuleRunner:
     )
 
 
+class DockerImageTagsRequestPlugin(DockerImageTagsRequest):
+    pass
+
+
 def assert_build(
     rule_runner: RuleRunner,
     address: Address,
@@ -86,6 +95,7 @@ def assert_build(
     copy_sources: tuple[str, ...] = (),
     build_context_snapshot: Snapshot = EMPTY_SNAPSHOT,
     version_tags: tuple[str, ...] = (),
+    plugin_tags: tuple[str, ...] = (),
 ) -> None:
     tgt = rule_runner.get_target(address)
 
@@ -148,6 +158,9 @@ def run_process_mock(process: Process) -> FallibleProcessResult:
             global_options,
             DockerBinary("/dummy/docker"),
             KeepSandboxes.never,
+            UnionMembership.from_rules(
+                [UnionRule(DockerImageTagsRequest, DockerImageTagsRequestPlugin)]
+            ),
         ],
         mock_gets=[
             MockGet(
@@ -160,6 +173,11 @@ def run_process_mock(process: Process) -> FallibleProcessResult:
                 input_type=WrappedTargetRequest,
                 mock=lambda _: WrappedTarget(tgt),
             ),
+            MockGet(
+                output_type=DockerImageTags,
+                input_type=DockerImageTagsRequestPlugin,
+                mock=lambda _: DockerImageTags(plugin_tags),
+            ),
             MockGet(
                 output_type=FallibleProcessResult,
                 input_type=Process,
@@ -1049,3 +1067,27 @@ def test_image_ref_formatting(test: ImageRefTest) -> None:
             field_set.image_refs(test.default_repository, registries, interpolation_context)
             == test.expect_refs
         )
+
+
+def test_docker_image_tags_from_plugin_hook(rule_runner: RuleRunner) -> None:
+    rule_runner.write_files({"docker/test/BUILD": 'docker_image(name="plugin")'})
+
+    def check_docker_proc(process: Process):
+        assert process.argv == (
+            "/dummy/docker",
+            "build",
+            "--tag",
+            "plugin:latest",
+            "--tag",
+            "plugin:1.2.3",
+            "--file",
+            "docker/test/Dockerfile",
+            ".",
+        )
+
+    assert_build(
+        rule_runner,
+        Address("docker/test", target_name="plugin"),
+        process_assertions=check_docker_proc,
+        plugin_tags=("1.2.3",),
+    )