Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
59 changes: 59 additions & 0 deletions docs/docs/helm/deployments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,65 @@ You should always favor using static values (or value files) VS dynamic values i
Be careful when chossing the values that are going to be calculated dynamically.
:::

## Using custom resource definitions

Pants uses [hikaru](https://github.com/haxsaw/hikaru) to analyze Helm charts and detect required first-party Docker images by resolving Pants' target addresses. However, by default, hikaru cannot parse custom resource definitions (CRDs), so Pants will not automatically detect Docker images referenced within CRDs.

To enable Pants to recognize Docker images in your CRDs, you need to extend hikaru with your custom resource definitions. This process is documented in the [hikaru CRD guide](https://hikaru.readthedocs.io/en/latest/crd.html).

For example, consider the following CRD manifest:

```apiVersion: pants/v1alpha1
kind: CustomResourceDefinition
metadata:
name: crd_foo
spec:
containers:
- name: myapp-container
image: busybox:1.28
initContainers:
- name: init-service
image: busybox:1.29
```

By default, Pants will not be able to parse Docker image references inside your CRDs, because the CRD is not registered with Hikaru. To enable Pants to recognize these images, you need to define and register your custom resource with Hikaru.

Create a Python file at `pantsbuild/crd.py` with content similar to:
```

from __future__ import annotations
from hikaru.model.rel_1_28.v1 import *

from hikaru import (HikaruBase, HikaruDocumentBase,
set_default_release)
from hikaru.crd import HikaruCRDDocumentMixin
from typing import Optional, List
from dataclasses import dataclass

set_default_release("rel_1_28")

@dataclass
class ContainersSpec(HikaruBase):
containers: List[Container]
initContainers: List[Container]

@dataclass
class CRD(HikaruDocumentBase, HikaruCRDDocumentMixin):
spec: ContainersSpec
metadata: ObjectMeta
apiVersion: str = f"pants/v1alpha1"
kind: str = "CustomResourceDefinition"
```
For more details on extending Hikaru with your CRDs, see the official documentation: https://hikaru.readthedocs.io/en/latest/crd.html.

After defining your CRD Python class, you must register the new file and class name with Pants:

```
[helm-k8s-parser.crd]
"pantsbuild/crd.py"="CRD"
"pantsbuild/crd_cron2.py"="CRD2"
```

## Third party chart artifacts

Previous examples on the usage of the `helm_deployment` target are all based on the fact that the deployment declares a dependency on a Helm chart that is also part of the same repository. Since charts support having dependencies with other charts in the same repository or with external 3rd party Helm artifacts (declared as `helm_artifact`), all that dependency resolution is handled for us.
Expand Down
2 changes: 2 additions & 0 deletions docs/notes/2.30.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The deprecation period for the `experimental_test_shell_command` alias has expir

### General

Add support for custom resource definitions to the k8s-parser. This allows users to define custom Kubernetes resources in their codebase, which can then be parsed and used by Pants.

### Goals

### Backends
Expand Down
41 changes: 38 additions & 3 deletions src/python/pants/backend/helm/subsystems/k8s_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

from __future__ import annotations

import json
import logging
import pkgutil
from collections import defaultdict
from dataclasses import dataclass
from pathlib import PurePath
from typing import Any
Expand All @@ -24,6 +26,7 @@
from pants.engine.fs import CreateDigest, FileContent, FileEntry
from pants.engine.intrinsics import create_digest, execute_process
from pants.engine.rules import collect_rules, implicitly, rule
from pants.option.option_types import DictOption
from pants.util.logging import LogLevel
from pants.util.strutil import pluralize, softwrap

Expand All @@ -48,13 +51,27 @@ class HelmKubeParserSubsystem(PythonToolRequirementsBase):
]

register_interpreter_constraints = True
crd = DictOption[str](
help=softwrap(
"""
Additional custom resource definitions be made available to all Helm processes
or during value interpolation.
Example:
[helm-k8s-parser.crd]
"filename1"="classname1"
"filename2"="classname2"
"""
),
default={},
)

default_lockfile_resource = (_HELM_K8S_PARSER_PACKAGE, "k8s_parser.lock")


@dataclass(frozen=True)
class _HelmKubeParserTool:
pex: VenvPex
crd: dict[str] = defaultdict


@rule
Expand All @@ -63,6 +80,7 @@ async def build_k8s_parser_tool(
pex_environment: PexEnvironment,
) -> _HelmKubeParserTool:
parser_sources = pkgutil.get_data(_HELM_K8S_PARSER_PACKAGE, _HELM_K8S_PARSER_SOURCE)

if not parser_sources:
raise ValueError(
f"Unable to find source to {_HELM_K8S_PARSER_SOURCE!r} in {_HELM_K8S_PARSER_PACKAGE}"
Expand All @@ -71,7 +89,24 @@ async def build_k8s_parser_tool(
parser_file_content = FileContent(
path="__k8s_parser.py", content=parser_sources, is_executable=True
)
parser_digest = await create_digest(CreateDigest([parser_file_content]))

digest_sources = [parser_file_content]

modulename_classname = []
if k8s_parser.crd != {}:
for file, classname in k8s_parser.crd.items():
crd_sources = open(file, "rb").read()
if not crd_sources:
raise ValueError(
f"Unable to find source to customer resource definition in {_HELM_K8S_PARSER_PACKAGE}"
)
unique_name = f"_crd_source_{hash(file)}"
parser_file_content_source = FileContent(
path=f"{unique_name}.py", content=crd_sources, is_executable=False
)
digest_sources.append(parser_file_content_source)
modulename_classname.append((unique_name, classname))
parser_digest = await create_digest(CreateDigest(digest_sources))

# We use copies of site packages because hikaru gets confused with symlinked packages
# The core hikaru package tries to load the packages containing the kubernetes-versioned models
Expand All @@ -90,7 +125,7 @@ async def build_k8s_parser_tool(
),
**implicitly(),
)
return _HelmKubeParserTool(parser_pex)
return _HelmKubeParserTool(parser_pex, json.dumps(modulename_classname))


@dataclass(frozen=True)
Expand Down Expand Up @@ -139,7 +174,7 @@ async def parse_kube_manifest(
**implicitly(
VenvPexProcess(
tool.pex,
argv=[request.file.path],
argv=[request.file.path, tool.crd],
input_digest=file_digest,
description=f"Analyzing Kubernetes manifest {request.file.path}",
level=LogLevel.DEBUG,
Expand Down
29 changes: 29 additions & 0 deletions src/python/pants/backend/helm/subsystems/k8s_parser_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,29 @@

from __future__ import annotations

import json
import re
import sys

from hikaru import load_full_yaml # pants: no-infer-dep
from hikaru.crd import register_crd_class # pants: no-infer-dep


def _import_crd_source(modulename_classname: tuple[str, str]):
"""Dynamically import the CRD source module."""
try:
import importlib

module_name = modulename_classname[0]
class_name = modulename_classname[1]
crd_module = importlib.import_module(module_name)
return getattr(crd_module, class_name, None)
except ImportError as e:
print(f"Error: Failed to import CRD module: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: Failed to register CRD: {e}", file=sys.stderr)
sys.exit(1)


def remove_comment_only_manifests(manifests: str) -> str:
Expand All @@ -22,6 +41,16 @@ def remove_comment_only_manifests(manifests: str) -> str:


def main(args: list[str]):
crd = args[1] if len(args) > 1 else None
crd = json.loads(crd)
if crd != "":
for modulename_classname in crd:
crd_class = _import_crd_source(modulename_classname)
if crd_class is None:
print("Error: CRD class not found in __crd_source[...].", file=sys.stderr)
sys.exit(1)
register_crd_class(crd_class, "crd", is_namespaced=False)

input_filename = args[0]

found_image_refs: dict[tuple[int, str], str] = {}
Expand Down
97 changes: 96 additions & 1 deletion src/python/pants/backend/helm/subsystems/k8s_parser_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
ParsedKubeManifest,
ParseKubeManifestRequest,
)
from pants.backend.helm.testutil import K8S_POD_FILE
from pants.backend.helm.testutil import K8S_CRD_FILE_IMAGE, K8S_POD_FILE
from pants.backend.helm.utils.yaml import YamlPath
from pants.engine.fs import CreateDigest, Digest, DigestEntries, FileContent, FileEntry
from pants.engine.rules import QueryRule
from pants.testutil.pants_integration_test import setup_tmpdir
from pants.testutil.rule_runner import PYTHON_BOOTSTRAP_ENV, RuleRunner


Expand Down Expand Up @@ -80,3 +81,97 @@ def test_parser_returns_no_image_refs(rule_runner: RuleRunner) -> None:
)

assert len(parsed_manifest.found_image_refs) == 0


def test_crd_parser_can_run(rule_runner: RuleRunner) -> None:
file_digest_python = {
"crd.py": dedent(
"""

from __future__ import annotations
from hikaru.model.rel_1_28.v1 import *

from hikaru import (HikaruBase, HikaruDocumentBase,
set_default_release)
from hikaru.crd import HikaruCRDDocumentMixin
from typing import Optional, List
from dataclasses import dataclass

set_default_release("rel_1_28")

@dataclass
class ContainersSpec(HikaruBase):
containers: List[Container]
initContainers: List[Container]

@dataclass
class CRD(HikaruDocumentBase, HikaruCRDDocumentMixin):
spec: ContainersSpec
metadata: ObjectMeta
apiVersion: str = f"pants/v1alpha1"
kind: str = "CustomResourceDefinition"

"""
)
}

file_digest = rule_runner.request(
Digest, [CreateDigest([FileContent("crd.yaml", K8S_CRD_FILE_IMAGE.encode("utf-8"))])]
)
file_entries = rule_runner.request(DigestEntries, [file_digest])
with setup_tmpdir(file_digest_python) as tmpdir:
rule_runner.set_options([f'--helm-k8s-parser-crd={{"{tmpdir}/crd.py": "CRD"}}'])
parsed_manifest = rule_runner.request(
ParsedKubeManifest, [ParseKubeManifestRequest(file=cast(FileEntry, file_entries[0]))]
)

expected_image_refs = [
ParsedImageRefEntry(0, YamlPath.parse("/spec/containers/0/image"), "busybox:1.28"),
ParsedImageRefEntry(0, YamlPath.parse("/spec/initContainers/0/image"), "busybox:1.29"),
]
assert parsed_manifest.found_image_refs == tuple(expected_image_refs)


def test_crd_parser_class_not_found_error(rule_runner: RuleRunner) -> None:
file_digest_python = {
"crd.py": dedent(
"""

from __future__ import annotations
from hikaru.model.rel_1_28.v1 import *

from hikaru import (HikaruBase, HikaruDocumentBase,
set_default_release)
from hikaru.crd import HikaruCRDDocumentMixin
from typing import Optional, List
from dataclasses import dataclass

set_default_release("rel_1_28")

@dataclass
class ContainersSpec(HikaruBase):
containers: List[Container]
initContainers: List[Container]

@dataclass
class CustomResourceDefinition(HikaruDocumentBase, HikaruCRDDocumentMixin):
spec: ContainersSpec
metadata: ObjectMeta
apiVersion: str = f"pants/v1alpha1"
kind: str = "CustomResourceDefinition"

"""
)
}

file_digest = rule_runner.request(
Digest, [CreateDigest([FileContent("crd.yaml", K8S_CRD_FILE_IMAGE.encode("utf-8"))])]
)
file_entries = rule_runner.request(DigestEntries, [file_digest])
with setup_tmpdir(file_digest_python) as tmpdir:
rule_runner.set_options([f'--helm-k8s-parser-crd={{"{tmpdir}/crd.py": "CRD"}}'])
with pytest.raises(Exception):
rule_runner.request(
ParsedKubeManifest,
[ParseKubeManifestRequest(file=cast(FileEntry, file_entries[0]))],
)
16 changes: 16 additions & 0 deletions src/python/pants/backend/helm/testutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,22 @@ def gen_chart_file(
"""
)

K8S_CRD_FILE_IMAGE = dedent(
"""
apiVersion: pants/v1alpha1
kind: CustomResourceDefinition
metadata:
name: crd_foo
spec:
containers:
- name: myapp-container
image: busybox:1.28
initContainers:
- name: init-service
image: busybox:1.29
"""
)

HELM_TEMPLATE_HELPERS_FILE = dedent(
"""\
{{- define "fullname" -}}
Expand Down
Loading