Repository: pantsbuild/pants

Issue:
Docker image tags should not be forced to lower case
**Describe the bug**

The `docker_image` target generates the docker image ref (name) based on registry, repository and tag. It is only the "name components" i.e. the repository, that is mandated to be in all lowercase, however Pants is forcing the tag to lowercase:

https://github.com/pantsbuild/pants/blob/22fb87f0dc1977b63a987c3de172493a45f38666/src/python/pants/backend/docker/goals/package_image.py#L77-L83


**Pants version**
Which version of Pants are you using?

On `main`.

**OS**
Are you encountering the bug on MacOS, Linux, or both?
`Any and all`.

**Additional info**
Add any other information about the problem here, such as attachments or links to gists, if relevant.

From https://docs.docker.com/engine/reference/commandline/tag/#extended-description

> An image name is made up of slash-separated name components, optionally prefixed by a registry hostname. The hostname must comply with standard DNS rules, but may not contain underscores. If a hostname is present, it may optionally be followed by a port number in the format :8080. If not present, the command uses Docker’s public registry located at registry-1.docker.io by default. Name components may contain lowercase letters, digits and separators. A separator is defined as a period, one or two underscores, or one or more dashes. A name component may not start or end with a separator.

> A tag name must be valid ASCII and may contain lowercase and uppercase letters, digits, underscores, periods and dashes. A tag name may not start with a period or a dash and may contain a maximum of 128 characters.

We may want to introduce additional checks on the image ref components in Pants, to aid pinpointing where there may be an issue in order to "fail fast" with a clear message that would be more helpful than relying on other tools to catch this much later with various degree of transparency.


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
@@ -77,9 +77,7 @@ def format_tag(self, tag: str, interpolation_context: DockerInterpolationContext
         source = DockerInterpolationContext.TextSource(
             address=self.address, target_alias="docker_image", field_alias=self.tags.alias
         )
-        return interpolation_context.format(
-            tag, source=source, error_cls=DockerImageTagValueError
-        ).lower()
+        return interpolation_context.format(tag, source=source, error_cls=DockerImageTagValueError)
 
     def format_repository(
         self, default_repository: str, interpolation_context: DockerInterpolationContext
diff --git a/src/python/pants/testutil/pytest_util.py b/src/python/pants/testutil/pytest_util.py
--- a/src/python/pants/testutil/pytest_util.py
+++ b/src/python/pants/testutil/pytest_util.py
@@ -31,11 +31,17 @@ def no_exception():
     When declaring parametrized tests, the test function can take a exceptions
     expectation as input, and always use a with-block for the code under test.
 
-        @pytest.mark.parametrize('answer, expect_raises', [
-            (42, no_exception()),
-            (12, pytest.raises(WrongAnswer)),
-        ])
-        def test_search_for_the_meaning_of_life_universe_and_everything(answer, expect_raises):
+        @pytest.mark.parametrize(
+            "answer, expect_raises",
+            [
+                (42, no_exception()),
+                (12, pytest.raises(WrongAnswer)),
+            ]
+        )
+        def test_search_for_the_meaning_of_life_universe_and_everything(
+            answer: int,
+            expect_raises: typing.ContextManager,
+        ):
             with expect_raises:
                 computer.validate_result(answer)
     """


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
@@ -980,3 +980,28 @@ def test_get_context_root(
 )
 def test_parse_image_id_from_docker_build_output(expected: str, stdout: str, stderr: str) -> None:
     assert expected == parse_image_id_from_docker_build_output(stdout.encode(), stderr.encode())
+
+
+@pytest.mark.parametrize(
+    "raw_values, expect_raises, image_refs",
+    [
+        (dict(name="lowercase"), no_exception(), ("lowercase:latest",)),
+        (dict(name="CamelCase"), no_exception(), ("camelcase:latest",)),
+        (dict(image_tags=["CamelCase"]), no_exception(), ("image:CamelCase",)),
+        (dict(registries=["REG1.example.net"]), no_exception(), ("REG1.example.net/image:latest",)),
+    ],
+)
+def test_image_ref_formatting(
+    raw_values: dict, expect_raises: ContextManager, image_refs: tuple[str, ...]
+) -> None:
+    address = Address("test", target_name=raw_values.pop("name", "image"))
+    tgt = DockerImageTarget(raw_values, address)
+    field_set = DockerFieldSet.create(tgt)
+    default_repository = "{name}"
+    registries = DockerRegistries.from_dict({})
+    interpolation_context = DockerInterpolationContext.from_dict({})
+    with expect_raises:
+        assert (
+            field_set.image_refs(default_repository, registries, interpolation_context)
+            == image_refs
+        )