Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/python/pants/backend/experimental/helm/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from pants.backend.helm.dependency_inference import deployment
from pants.backend.helm.goals import deploy, lint, package, publish, tailor
from pants.backend.helm.util_rules import chart_values
from pants.backend.helm.subsystems.helm import HelmSubsystem
from pants.backend.helm.target_types import (
HelmArtifactTarget,
Expand Down Expand Up @@ -33,6 +34,7 @@ def rules():
*deploy.rules(),
*deployment.rules(),
*package.rules(),
*chart_values.rules(),
*publish.rules(),
*tailor.rules(),
*unittest_rules(),
Expand Down
48 changes: 48 additions & 0 deletions src/python/pants/backend/helm/dependency_inference/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
HelmChartDependenciesField,
HelmChartMetaSourceField,
HelmChartTarget,
HelmChartValuesField,
)
from pants.backend.helm.target_types import rules as helm_target_types_rules
from pants.backend.helm.util_rules import chart_metadata
Expand All @@ -25,7 +26,9 @@
parse_chart_metadata_from_field,
)
from pants.engine.addresses import Address
from pants.engine.internals.build_files import maybe_resolve_address
from pants.engine.internals.graph import determine_explicitly_provided_dependencies
from pants.engine.internals.native_engine import AddressInput
from pants.engine.internals.selectors import concurrently
from pants.engine.rules import collect_rules, implicitly, rule
from pants.engine.target import (
Expand Down Expand Up @@ -166,11 +169,56 @@ async def infer_chart_dependencies_via_metadata(
return InferredDependencies(dependencies)


@dataclass(frozen=True)
class HelmChartValuesDependenciesInferenceFieldSet(FieldSet):
required_fields = (HelmChartValuesField,)

values: HelmChartValuesField
dependencies: HelmChartDependenciesField


class InferHelmChartValuesDependenciesRequest(InferDependenciesRequest):
infer_from = HelmChartValuesDependenciesInferenceFieldSet


@rule(desc="Inferring Helm chart dependencies from values", level=LogLevel.DEBUG)
async def infer_chart_values_dependencies(
request: InferHelmChartValuesDependenciesRequest,
) -> InferredDependencies:
values = request.field_set.values.value
if not values:
return InferredDependencies([])

address = request.field_set.address
dependencies: OrderedSet[Address] = OrderedSet()

for value in values.values():
try:
address_input = AddressInput.parse(
value,
relative_to=address.spec_path,
description_of_origin=f"the `values` field of the `helm_chart` target {address}",
)
except Exception:
continue

maybe_addr = await maybe_resolve_address(address_input)
if isinstance(maybe_addr.val, Address):
dependencies.add(maybe_addr.val)

logger.debug(
f"Inferred {pluralize(len(dependencies), 'dependency')} from values "
f"for target at address: {address}"
)
return InferredDependencies(dependencies)


def rules():
return [
*collect_rules(),
*artifacts.rules(),
*helm_target_types_rules(),
*chart_metadata.rules(),
UnionRule(InferDependenciesRequest, InferHelmChartDependenciesRequest),
UnionRule(InferDependenciesRequest, InferHelmChartValuesDependenciesRequest),
]
24 changes: 23 additions & 1 deletion src/python/pants/backend/helm/goals/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,25 @@
import os
from dataclasses import dataclass

from pants.backend.helm.target_types import HelmChartFieldSet, HelmChartOutputPathField
from pants.backend.helm.target_types import (
HelmChartFieldSet,
HelmChartOutputPathField,
HelmChartValuesField,
)
from pants.backend.helm.util_rules.chart import HelmChartRequest, get_helm_chart
from pants.backend.helm.util_rules.chart_metadata import HelmChartMetadata
from pants.backend.helm.util_rules.chart_values import (
ResolveHelmChartValuesRequest,
resolve_helm_chart_values,
)
from pants.backend.helm.util_rules.tool import HelmProcess
from pants.core.goals.package import BuiltPackage, BuiltPackageArtifact, PackageFieldSet
from pants.engine.fs import AddPrefix, CreateDigest, Directory, RemovePrefix
from pants.engine.intrinsics import create_digest, digest_to_snapshot, remove_prefix
from pants.engine.process import execute_process_or_raise
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
from pants.engine.unions import UnionRule
from pants.util.frozendict import FrozenDict
from pants.util.logging import LogLevel

logger = logging.getLogger(__name__)
Expand All @@ -38,6 +47,7 @@ def create(cls, relpath: str, info: HelmChartMetadata) -> BuiltHelmArtifact:
@dataclass(frozen=True)
class HelmPackageFieldSet(HelmChartFieldSet, PackageFieldSet):
output_path: HelmChartOutputPathField
values: HelmChartValuesField


@rule(desc="Package Helm chart", level=LogLevel.DEBUG)
Expand All @@ -49,6 +59,18 @@ async def run_helm_package(field_set: HelmPackageFieldSet) -> BuiltPackage:
create_digest(CreateDigest([Directory(result_dir)])),
)

# If values are specified, resolve Docker image refs and inject into values.yaml.
inline_values = field_set.values.value
if inline_values:
chart = await resolve_helm_chart_values(
ResolveHelmChartValuesRequest(
chart=chart,
values=FrozenDict(inline_values),
spec_path=field_set.address.spec_path,
),
**implicitly(),
)

process_output_file = os.path.join(result_dir, f"{chart.info.artifact_name}.tgz")

process_result = await execute_process_or_raise(
Expand Down
37 changes: 37 additions & 0 deletions src/python/pants/backend/helm/target_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,41 @@ class HelmChartVersionField(StringField):
)


class HelmChartValuesField(DictStringToStringField, AsyncFieldMixin):
alias = "values"
required = False
help = help_text(
"""
Individual values to inject into the chart's `values.yaml` when packaging.

Value names should be defined using dot-syntax as in the following example:

helm_chart(
values={
"nameOverride": "my_custom_name",
"image.pullPolicy": "Always",
},
)

Values that are valid target addresses pointing to `docker_image` targets will be
resolved to the fully qualified Docker image reference (e.g.
`myregistry.io/myimage:latest`). All other values are written as-is.

This is useful when deploying Helm charts via tools like ArgoCD that rely on default
values in the chart rather than using `helm_deployment` to set values at deploy time.

Example:

helm_chart(
values={
"image": "src/docker/helloworld",
"replicas": "3",
},
)
"""
)


class HelmChartTarget(Target):
alias = "helm_chart"
core_fields = (
Expand All @@ -214,6 +249,7 @@ class HelmChartTarget(Target):
HelmChartLintQuietField,
HelmChartRepositoryField,
HelmChartVersionField,
HelmChartValuesField,
HelmRegistriesField,
HelmSkipPushField,
HelmSkipLintField,
Expand All @@ -233,6 +269,7 @@ class HelmChartFieldSet(FieldSet):
dependencies: HelmChartDependenciesField
description: DescriptionField
version: HelmChartVersionField
values: HelmChartValuesField


class AllHelmChartTargets(Targets):
Expand Down
140 changes: 140 additions & 0 deletions src/python/pants/backend/helm/util_rules/chart_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

import logging
from dataclasses import dataclass

import yaml

from pants.backend.helm.util_rules import docker_image_ref
from pants.backend.helm.util_rules.chart import HelmChart
from pants.backend.helm.util_rules.docker_image_ref import (
ResolveDockerImageRefRequest,
resolve_docker_image_ref,
)
from pants.engine.addresses import Address
from pants.engine.engine_aware import EngineAwareParameter
from pants.engine.fs import (
CreateDigest,
DigestSubset,
FileContent,
MergeDigests,
PathGlobs,
)
from pants.engine.internals.build_files import maybe_resolve_address
from pants.engine.internals.native_engine import AddressInput, AddressParseException
from pants.engine.intrinsics import (
create_digest,
digest_subset_to_digest,
digest_to_snapshot,
get_digest_contents,
)
from pants.engine.rules import collect_rules, implicitly, rule
from pants.util.frozendict import FrozenDict
from pants.util.logging import LogLevel

logger = logging.getLogger(__name__)


def _set_nested_value(data: dict, dot_path: str, value: str) -> None:
"""Set a value in a nested dict using dot-notation path."""
keys = dot_path.split(".")
for key in keys[:-1]:
data = data.setdefault(key, {})
data[keys[-1]] = value


@dataclass(frozen=True)
class ResolveHelmChartValuesRequest(EngineAwareParameter):
"""Request to resolve helm chart values, replacing Docker target addresses with image refs."""

chart: HelmChart
values: FrozenDict[str, str]
spec_path: str

def debug_hint(self) -> str | None:
return self.chart.address.spec


@rule(desc="Resolve Helm chart values", level=LogLevel.DEBUG)
async def resolve_helm_chart_values(
request: ResolveHelmChartValuesRequest,
) -> HelmChart:
"""Resolve chart values (replacing Docker targets with image refs) and inject into
values.yaml."""
resolved_values: dict[str, str] = {}

for dot_path, value in request.values.items():
try:
address_input = AddressInput.parse(
value,
relative_to=request.spec_path,
description_of_origin="the `values` field of a `helm_chart` target",
)
except AddressParseException:
resolved_values[dot_path] = value
continue

maybe_addr = await maybe_resolve_address(address_input)
if not isinstance(maybe_addr.val, Address):
resolved_values[dot_path] = value
continue

result = await resolve_docker_image_ref(
ResolveDockerImageRefRequest(maybe_addr.val),
**implicitly(),
)
if result.ref:
logger.debug(
f"Resolved Docker image ref '{result.ref}' for value path '{dot_path}' "
f"from target {maybe_addr.val}."
)
resolved_values[dot_path] = result.ref
else:
resolved_values[dot_path] = value

chart = request.chart
contents = await get_digest_contents(chart.snapshot.digest)

values_content: bytes = b""
values_filename = "values.yaml"
for fc in contents:
if fc.path == "values.yaml" or fc.path == "values.yml":
values_content = fc.content
values_filename = fc.path
break

existing_values = (yaml.safe_load(values_content) or {}) if values_content else {}
for dot_path, value in resolved_values.items():
_set_nested_value(existing_values, dot_path, value)

new_values_content = yaml.safe_dump(existing_values, default_flow_style=False).encode("utf-8")

without_values = await digest_subset_to_digest(
DigestSubset(
chart.snapshot.digest,
PathGlobs(["**/*", f"!{values_filename}"]),
)
)
new_values_digest = await create_digest(
CreateDigest([FileContent(values_filename, new_values_content)])
)
new_snapshot = await digest_to_snapshot(
**implicitly(MergeDigests([without_values, new_values_digest]))
)

return HelmChart(
address=chart.address,
info=chart.info,
snapshot=new_snapshot,
artifact=chart.artifact,
)


def rules():
return [
*collect_rules(),
*docker_image_ref.rules(),
]
Loading