Repository: pantsbuild/pants

Issue:
support some sort of interpolation or dynamic generation of helm deployment namespace
**Is your feature request related to a problem? Please describe.**
We have some shared environments like `qa` or `stage`  where we generally would expect fixed namespaces to exist in k8s.  However, in `dev` we have both "shared" environments (team A deploys service foo from CI and team B expects to be able to integrate against it) and "playground" style namespaces like `devname-tmp-trying-out-deploying-with-pants`.  I'd like some way of deploying a cool new service to a "playground/personal" namespace, show it to another engineer who can do the same without conflict, while still having defined in pants the Official Namespace for CI/production.

**Describe the solution you'd like**
Some sort of string interpolation or other environmental variable injection to `helm_deployment.namespace`


**Describe alternatives you've considered**
* passing `-- --namespace=mynamesapce` which results in `InvalidHelmPassthroughArgs`

* Settings kube contexts as described in https://www.pantsbuild.org/docs/helm-deployments#deploying

This works great for a single chart, but has fricting when working on multiple applications since you need to either keep track of the current active context as you twiddle the namespaces, or (outside of pants in bash or whatnot) manage target:namespace mappings.


**Additional context**
New to both helm and the pants integration, but it is very spiffy!



Discussion:
`env()` isn't exactly what the ticket is asking for, but I think that helps reduce the toe stepping problem.  I"m not sure if the original interpolation request is more generally useful in helm-land.
I think this should be possible and easy to implement, it's something that could not just be useful when combined with the `create_namespace=True` setting, but also in CI cases in which the name of the namespace could depend on the value of an environment variable.

Something that I'm having in my mind on how to support this is something like:

```python
helm_deployment(
  namespace="prefix-{env.NAMESPACE_SUFFIX}"
)
```

Is that the same you were thinking of or there is a different approach that could work better?

Resolving patch:
diff --git a/src/python/pants/backend/helm/subsystems/helm.py b/src/python/pants/backend/helm/subsystems/helm.py
--- a/src/python/pants/backend/helm/subsystems/helm.py
+++ b/src/python/pants/backend/helm/subsystems/helm.py
@@ -23,6 +23,7 @@
 _VALID_PASSTHROUGH_FLAGS = [
     "--atomic",
     "--cleanup-on-fail",
+    "--create-namespace",
     "--debug",
     "--dry-run",
     "--force",
diff --git a/src/python/pants/backend/helm/target_types.py b/src/python/pants/backend/helm/target_types.py
--- a/src/python/pants/backend/helm/target_types.py
+++ b/src/python/pants/backend/helm/target_types.py
@@ -5,9 +5,10 @@
 
 import logging
 from dataclasses import dataclass
+from typing import Mapping
 
 from pants.backend.helm.resolve.remotes import ALL_DEFAULT_HELM_REGISTRIES
-from pants.base.deprecated import warn_or_error
+from pants.base.deprecated import deprecated, warn_or_error
 from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
 from pants.core.goals.package import OutputPathField
 from pants.core.goals.test import TestTimeoutField
@@ -38,6 +39,7 @@
     generate_multiple_sources_field_help_message,
 )
 from pants.util.docutil import bin_name
+from pants.util.memo import memoized_method
 from pants.util.strutil import help_text, softwrap
 from pants.util.value_interpolation import InterpolationContext, InterpolationError
 
@@ -429,7 +431,7 @@ class HelmDeploymentReleaseNameField(StringField):
 
 class HelmDeploymentNamespaceField(StringField):
     alias = "namespace"
-    help = "Kubernetes namespace for the given deployment."
+    help = help_text("""Kubernetes namespace for the given deployment.""")
 
 
 class HelmDeploymentDependenciesField(Dependencies):
@@ -449,7 +451,7 @@ class HelmDeploymentSourcesField(MultipleSourcesField):
     help = "Helm configuration files for a given deployment."
 
 
-class HelmDeploymentValuesField(DictStringToStringField):
+class HelmDeploymentValuesField(DictStringToStringField, AsyncFieldMixin):
     alias = "values"
     required = False
     help = help_text(
@@ -472,7 +474,7 @@ class HelmDeploymentValuesField(DictStringToStringField):
         ```
         helm_deployment(
             values={
-                "configmap.deployedAt": "{env.DEPLOY_TIME}",
+                "configmap.deployedAt": f"{env('DEPLOY_TIME')}",
             },
         )
         ```
@@ -482,12 +484,56 @@ class HelmDeploymentValuesField(DictStringToStringField):
         """
     )
 
+    @memoized_method
+    @deprecated("2.19.0.dev0", start_version="2.18.0.dev0")
+    def format_with(
+        self, interpolation_context: InterpolationContext, *, ignore_missing: bool = False
+    ) -> dict[str, str]:
+        source = InterpolationContext.TextSource(
+            self.address,
+            target_alias=HelmDeploymentTarget.alias,
+            field_alias=HelmDeploymentValuesField.alias,
+        )
+
+        def format_value(text: str) -> str | None:
+            try:
+                return interpolation_context.format(
+                    text,
+                    source=source,
+                )
+            except InterpolationError as err:
+                if ignore_missing:
+                    return None
+                raise err
+
+        result = {}
+        default_curr_value: dict[str, str] = {}
+        current_value: Mapping[str, str] = self.value or default_curr_value
+        for key, value in current_value.items():
+            formatted_value = format_value(value)
+            if formatted_value is not None:
+                result[key] = formatted_value
+
+        if result != current_value:
+            warn_or_error(
+                "2.19.0.dev0",
+                "Using the {env.VAR_NAME} interpolation syntax",
+                "Use the new `f\"prefix-{env('VAR_NAME')}\" syntax for interpolating values from environment variables.",
+                start_version="2.18.0.dev0",
+            )
+
+        return result
+
 
 class HelmDeploymentCreateNamespaceField(BoolField):
     alias = "create_namespace"
     default = False
     help = "If true, the namespace will be created if it doesn't exist."
 
+    removal_version = "2.19.0.dev0"
+    # TODO This causes and error in the parser as it believes it is using it as the `removal_version` attribute.
+    # removal_hint = "Use the passthrough argument `--create-namespace` instead."
+
 
 class HelmDeploymentNoHooksField(BoolField):
     alias = "no_hooks"
@@ -563,33 +609,13 @@ class HelmDeploymentFieldSet(FieldSet):
     post_renderers: HelmDeploymentPostRenderersField
     enable_dns: HelmDeploymentEnableDNSField
 
+    @deprecated(
+        "2.19.0.dev0", "Use `field_set.values.format_with()` instead.", start_version="2.18.0.dev0"
+    )
     def format_values(
         self, interpolation_context: InterpolationContext, *, ignore_missing: bool = False
     ) -> dict[str, str]:
-        source = InterpolationContext.TextSource(
-            self.address,
-            target_alias=HelmDeploymentTarget.alias,
-            field_alias=HelmDeploymentValuesField.alias,
-        )
-
-        def format_value(text: str) -> str | None:
-            try:
-                return interpolation_context.format(
-                    text,
-                    source=source,
-                )
-            except InterpolationError as err:
-                if ignore_missing:
-                    return None
-                raise err
-
-        result = {}
-        for key, value in (self.values.value or {}).items():
-            formatted_value = format_value(value)
-            if formatted_value is not None:
-                result[key] = formatted_value
-
-        return result
+        return self.values.format_with(interpolation_context, ignore_missing=ignore_missing)
 
 
 class AllHelmDeploymentTargets(Targets):
diff --git a/src/python/pants/backend/helm/util_rules/renderer.py b/src/python/pants/backend/helm/util_rules/renderer.py
--- a/src/python/pants/backend/helm/util_rules/renderer.py
+++ b/src/python/pants/backend/helm/util_rules/renderer.py
@@ -305,14 +305,16 @@ async def setup_render_helm_deployment_process(
 
     merged_digests = await Get(Digest, MergeDigests(input_digests))
 
+    # Calculate values that may depend on the interpolation context
     interpolation_context = await _build_interpolation_context(helm_subsystem)
+    is_render_cmd = request.cmd == HelmDeploymentCmd.RENDER
 
     release_name = (
         request.field_set.release_name.value
         or request.field_set.address.target_name.replace("_", "-")
     )
-    inline_values = request.field_set.format_values(
-        interpolation_context, ignore_missing=request.cmd == HelmDeploymentCmd.RENDER
+    inline_values = request.field_set.values.format_with(
+        interpolation_context, ignore_missing=is_render_cmd
     )
 
     def maybe_escape_string_value(value: str) -> str:
@@ -328,6 +330,11 @@ def maybe_escape_string_value(value: str) -> str:
         if request.post_renderer
         else ProcessCacheScope.SUCCESSFUL
     )
+
+    extra_args = list(request.extra_argv)
+    if "--create-namespace" not in extra_args and request.field_set.create_namespace.value:
+        extra_args.append("--create-namespace")
+
     process = HelmProcess(
         argv=[
             request.cmd.value,
@@ -343,7 +350,6 @@ def maybe_escape_string_value(value: str) -> str:
                 if request.field_set.namespace.value
                 else ()
             ),
-            *(("--create-namespace",) if request.field_set.create_namespace.value else ()),
             *(("--skip-crds",) if request.field_set.skip_crds.value else ()),
             *(("--no-hooks",) if request.field_set.no_hooks.value else ()),
             *(("--output-dir", output_dir) if output_dir else ()),
@@ -362,7 +368,7 @@ def maybe_escape_string_value(value: str) -> str:
                 if inline_values
                 else ()
             ),
-            *request.extra_argv,
+            *extra_args,
         ],
         extra_env=env,
         extra_immutable_input_digests=immutable_input_digests,


Test patch:
diff --git a/src/python/pants/backend/helm/goals/deploy_test.py b/src/python/pants/backend/helm/goals/deploy_test.py
--- a/src/python/pants/backend/helm/goals/deploy_test.py
+++ b/src/python/pants/backend/helm/goals/deploy_test.py
@@ -67,8 +67,7 @@ def test_run_helm_deploy(rule_runner: RuleRunner) -> None:
               helm_deployment(
                 name="foo",
                 description="Foo deployment",
-                namespace="uat",
-                create_namespace=True,
+                namespace=f"uat-{env('NS_SUFFIX')}",
                 skip_crds=True,
                 no_hooks=True,
                 dependencies=["//src/chart", "//src/docker/myimage"],
@@ -77,7 +76,7 @@ def test_run_helm_deploy(rule_runner: RuleRunner) -> None:
                     "key": "foo",
                     "amount": "300",
                     "long_string": "This is a long string",
-                    "build_number": "{env.BUILD_NUMBER}",
+                    "build_number": f"{env('BUILD_NUMBER')}",
                 },
                 timeout=150,
               )
@@ -105,6 +104,7 @@ def test_run_helm_deploy(rule_runner: RuleRunner) -> None:
     )
 
     expected_build_number = "34"
+    expected_ns_suffix = "quxx"
     expected_value_files_order = [
         "common.yaml",
         "bar.yaml",
@@ -116,14 +116,13 @@ def test_run_helm_deploy(rule_runner: RuleRunner) -> None:
         "subdir/last.yaml",
     ]
 
-    extra_env_vars = ["BUILD_NUMBER"]
-    deploy_args = ["--kubeconfig", "./kubeconfig"]
+    deploy_args = ["--kubeconfig", "./kubeconfig", "--create-namespace"]
     deploy_process = _run_deployment(
         rule_runner,
         "src/deployment",
         "foo",
-        args=[f"--helm-args={repr(deploy_args)}", f"--helm-extra-env-vars={repr(extra_env_vars)}"],
-        env={"BUILD_NUMBER": expected_build_number},
+        args=[f"--helm-args={repr(deploy_args)}"],
+        env={"BUILD_NUMBER": expected_build_number, "NS_SUFFIX": expected_ns_suffix},
     )
 
     helm = rule_runner.request(HelmBinary, [])
@@ -137,8 +136,7 @@ def test_run_helm_deploy(rule_runner: RuleRunner) -> None:
         "--description",
         '"Foo deployment"',
         "--namespace",
-        "uat",
-        "--create-namespace",
+        f"uat-{expected_ns_suffix}",
         "--skip-crds",
         "--no-hooks",
         "--post-renderer",
@@ -160,6 +158,7 @@ def test_run_helm_deploy(rule_runner: RuleRunner) -> None:
         "150s",
         "--kubeconfig",
         "./kubeconfig",
+        "--create-namespace",
     )