diff --git a/docs/docs/helm/deployments.mdx b/docs/docs/helm/deployments.mdx index 9d6f11cdbbb..c0da831ae68 100644 --- a/docs/docs/helm/deployments.mdx +++ b/docs/docs/helm/deployments.mdx @@ -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. diff --git a/docs/notes/2.30.x.md b/docs/notes/2.30.x.md index da88a0f53c0..db0f4fe4711 100644 --- a/docs/notes/2.30.x.md +++ b/docs/notes/2.30.x.md @@ -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 diff --git a/src/python/pants/backend/helm/subsystems/k8s_parser.py b/src/python/pants/backend/helm/subsystems/k8s_parser.py index 463ac5c2ebf..0fa7d1150fa 100644 --- a/src/python/pants/backend/helm/subsystems/k8s_parser.py +++ b/src/python/pants/backend/helm/subsystems/k8s_parser.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import logging import pkgutil from dataclasses import dataclass @@ -24,6 +25,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 @@ -48,6 +50,19 @@ 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") @@ -55,6 +70,7 @@ class HelmKubeParserSubsystem(PythonToolRequirementsBase): @dataclass(frozen=True) class _HelmKubeParserTool: pex: VenvPex + crd: str @rule @@ -63,6 +79,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}" @@ -71,7 +88,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 @@ -90,7 +124,7 @@ async def build_k8s_parser_tool( ), **implicitly(), ) - return _HelmKubeParserTool(parser_pex) + return _HelmKubeParserTool(parser_pex, json.dumps(modulename_classname)) @dataclass(frozen=True) @@ -139,7 +173,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, diff --git a/src/python/pants/backend/helm/subsystems/k8s_parser_main.py b/src/python/pants/backend/helm/subsystems/k8s_parser_main.py index 94d3a7260fd..796f484df0c 100644 --- a/src/python/pants/backend/helm/subsystems/k8s_parser_main.py +++ b/src/python/pants/backend/helm/subsystems/k8s_parser_main.py @@ -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: @@ -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] = {} diff --git a/src/python/pants/backend/helm/subsystems/k8s_parser_test.py b/src/python/pants/backend/helm/subsystems/k8s_parser_test.py index 1834a6b7841..a01ac781c08 100644 --- a/src/python/pants/backend/helm/subsystems/k8s_parser_test.py +++ b/src/python/pants/backend/helm/subsystems/k8s_parser_test.py @@ -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 @@ -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]))], + ) diff --git a/src/python/pants/backend/helm/testutil.py b/src/python/pants/backend/helm/testutil.py index 6045f5345eb..19b6c5e8dbf 100644 --- a/src/python/pants/backend/helm/testutil.py +++ b/src/python/pants/backend/helm/testutil.py @@ -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" -}}