diff --git a/flux_local/helm.py b/flux_local/helm.py index 5e4bc2ff..574f96a7 100644 --- a/flux_local/helm.py +++ b/flux_local/helm.py @@ -68,6 +68,8 @@ def _chart_name(release: HelmRelease, repo: HelmRepository | None) -> str: """Return the helm chart name used for the helm template command.""" + if not release.chart: + raise HelmException(f"HelmRelease {release.name} has no chart") if release.chart.repo_kind == HELM_REPOSITORY: if repo and repo.repo_type == REPO_TYPE_OCI: return f"{repo.url}/{release.chart.name}" @@ -210,6 +212,8 @@ async def template( The values will come from the `HelmRelease` object. """ + if not release.chart: + raise HelmException(f"HelmRelease {release.name} has no chart") if options is None: options = Options() repo = next( diff --git a/flux_local/manifest.py b/flux_local/manifest.py index 2d033f23..a06615fd 100644 --- a/flux_local/manifest.py +++ b/flux_local/manifest.py @@ -109,21 +109,14 @@ class HelmChart(BaseManifest): @classmethod def parse_doc(cls, doc: dict[str, Any], default_namespace: str) -> "HelmChart": - """Parse a HelmChart from a HelmRelease resource object.""" - _check_version(doc, HELM_RELEASE_DOMAIN) - if not (spec := doc.get("spec")): + """Parse a HelmChart.""" + if not (chart_spec := doc.get("spec")): raise InputException(f"Invalid {cls} missing spec: {doc}") - if not (chart := spec.get("chart")): - raise InputException(f"Invalid {cls} missing spec.chart: {doc}") - if not (chart_spec := chart.get("spec")): - raise InputException(f"Invalid {cls} missing spec.chart.spec: {doc}") if not (chart := chart_spec.get("chart")): - raise InputException(f"Invalid {cls} missing spec.chart.spec.chart: {doc}") + raise InputException(f"Invalid {cls} missing spec.chart: {doc}") version = chart_spec.get("version") if not (source_ref := chart_spec.get("sourceRef")): - raise InputException( - f"Invalid {cls} missing spec.chart.spec.sourceRef: {doc}" - ) + raise InputException(f"Invalid {cls} missing spec.sourceRef: {doc}") if "name" not in source_ref: raise InputException(f"Invalid {cls} missing sourceRef fields: {doc}") return cls( @@ -145,6 +138,39 @@ def chart_name(self) -> str: return f"{self.repo_full_name}/{self.name}" +@dataclass +class CrossNamespaceSourceReference(BaseManifest): + """A representation of an instantiation of a cross-namespace object reference.""" + + api_version: Optional[str] + """API version of the referent.""" + + kind: str + """Kind of the referent.""" + + name: str + """Name of the referent.""" + + namespace: Optional[str] + """Namespace of the referent.""" + + @classmethod + def parse_doc( + cls, doc: dict[str, Any], default_namespace: str + ) -> "CrossNamespaceSourceReference": + """Parse a CrossNamespaceSourceReference.""" + if not (kind := doc.get("kind")): + raise InputException(f"Invalid {cls} missing kind: {doc}") + if not (name := doc.get("name")): + raise InputException(f"Invalid {cls} missing kind: {doc}") + return cls( + api_version=doc.get("apiVersion"), + kind=kind, + name=name, + namespace=doc.get("namespace", default_namespace), + ) + + @dataclass class ValuesReference(BaseManifest): """A reference to a resource containing values for a HelmRelease.""" @@ -179,9 +205,12 @@ class HelmRelease(BaseManifest): namespace: str """The namespace that owns the HelmRelease.""" - chart: HelmChart + chart: HelmChart | None """A mapping to a specific helm chart for this HelmRelease.""" + chart_ref: CrossNamespaceSourceReference | None + """A mapping to a cross-namespace helm chart object reference for this HelmRelease.""" + values: Optional[dict[str, Any]] = field(metadata={"serialize": "omit"}) """The values to install in the chart.""" @@ -201,7 +230,24 @@ def parse_doc(cls, doc: dict[str, Any]) -> "HelmRelease": raise InputException(f"Invalid {cls} missing metadata.name: {doc}") if not (namespace := metadata.get("namespace")): raise InputException(f"Invalid {cls} missing metadata.namespace: {doc}") - chart = HelmChart.parse_doc(doc, namespace) + if not (spec := doc.get("spec")): + raise InputException(f"Invalid {cls} missing spec: {doc}") + chart_doc = spec.get("chart") + chart_ref_doc = spec.get("chartRef") + if not chart_doc and not chart_ref_doc: + raise InputException( + f"Invalid {cls} none of spec.chart and spec.chartRef: {doc}" + ) + if chart_doc and chart_ref_doc: + raise InputException( + f"Invalid {cls} both of spec.chart and spec.chartRef: {doc}" + ) + chart = HelmChart.parse_doc(chart_doc, namespace) if chart_doc else None + chart_ref = ( + CrossNamespaceSourceReference.parse_doc(chart_ref_doc, namespace) + if chart_ref_doc + else None + ) spec = doc["spec"] values_from: list[ValuesReference] | None = None if values_from_dict := spec.get("valuesFrom"): @@ -212,6 +258,7 @@ def parse_doc(cls, doc: dict[str, Any]) -> "HelmRelease": name=name, namespace=namespace, chart=chart, + chart_ref=chart_ref, values=spec.get("values"), values_from=values_from, ) @@ -222,9 +269,12 @@ def release_name(self) -> str: return f"{self.namespace}-{self.name}" @property - def repo_name(self) -> str: + def repo_name(self) -> str | None: """Identifier for the HelmRepository identified in the HelmChart.""" - return f"{self.chart.repo_namespace}-{self.chart.repo_name}" + if self.chart: + return f"{self.chart.repo_namespace}-{self.chart.repo_name}" + else: + return None @property def namespaced_name(self) -> str: diff --git a/flux_local/tool/get.py b/flux_local/tool/get.py index 1a2034c1..b37a825b 100644 --- a/flux_local/tool/get.py +++ b/flux_local/tool/get.py @@ -127,6 +127,9 @@ async def run( # type: ignore[no-untyped-def] for cluster in manifest.clusters: for helmrelease in cluster.helm_releases: value = { k: v for k, v in helmrelease.compact_dict().items() if k in cols } + # TODO: Support resolving `chartRef` HelmRepository and OCIRepository. + if not helmrelease.chart: + continue value["revision"] = str(helmrelease.chart.version) value["chart"] = f"{helmrelease.namespace}-{helmrelease.chart.name}" value["source"] = helmrelease.chart.repo_name diff --git a/flux_local/tool/test.py b/flux_local/tool/test.py index 358cc816..e5c04e15 100644 --- a/flux_local/tool/test.py +++ b/flux_local/tool/test.py @@ -97,6 +97,8 @@ async def async_runtest(self) -> None: def active_repos(self) -> list[HelmRepository]: """Return HelpRepositories referenced by a HelmRelease.""" + if not self.helm_release.chart: + return [] repo_key = "-".join( [ self.helm_release.chart.repo_namespace, @@ -181,13 +183,15 @@ def collect(self) -> Generator[pytest.Item | pytest.Collector, None, None]: test_config=self.test_config, ) for helm_release in self.kustomization.helm_releases: - yield HelmReleaseTest.from_parent( - parent=self, - cluster=self.cluster, - kustomization=self.kustomization, - helm_release=helm_release, - test_config=self.test_config, - ) + # TODO: Only HelmRelease with `chart` can be tested. `chartRef` is not supported. + if helm_release.chart: + yield HelmReleaseTest.from_parent( + parent=self, + cluster=self.cluster, + kustomization=self.kustomization, + helm_release=helm_release, + test_config=self.test_config, + ) class ClusterCollector(pytest.Collector): diff --git a/flux_local/tool/visitor.py b/flux_local/tool/visitor.py index 43cb856a..b02b23c5 100644 --- a/flux_local/tool/visitor.py +++ b/flux_local/tool/visitor.py @@ -248,8 +248,11 @@ def __init__(self) -> None: @property def active_repos(self) -> list[HelmRepository]: """Return HelpRepositories referenced by a HelmRelease.""" + # NOTE: With Flux 2.3, a HelmRelease may reference an OCIRepository providing the chart. + # TODO: Support resolving HelmRepository `chartRef`. repo_keys: set[str] = { release.chart.repo_full_name for release in self.releases + if release.chart } return [repo for repo in self.repos if repo.repo_name in repo_keys] @@ -296,6 +299,7 @@ async def inflate( if active_repos := self.active_repos: helm.add_repos(active_repos) await helm.update() + # TODO: Only HelmRelease with `chart` can be inflated. `chartRef` is not supported. tasks = [ inflate_release( helm, @@ -304,6 +308,7 @@ async def inflate( options, ) for release in self.releases + if release.chart ] _LOGGER.debug("Waiting for inflate tasks to complete") await asyncio.gather(*tasks) diff --git a/tests/__snapshots__/test_git_repo.ambr b/tests/__snapshots__/test_git_repo.ambr index 835d560a..6f4d43c6 100644 --- a/tests/__snapshots__/test_git_repo.ambr +++ b/tests/__snapshots__/test_git_repo.ambr @@ -94,6 +94,15 @@ 'config_maps': list([ ]), 'helm_releases': list([ + dict({ + 'chart_ref': dict({ + 'kind': 'OCIRepository', + 'name': 'kyverno', + 'namespace': 'flux-system', + }), + 'name': 'kyverno', + 'namespace': 'kyverno', + }), dict({ 'chart': dict({ 'name': 'metallb', @@ -330,6 +339,15 @@ 'config_maps': list([ ]), 'helm_releases': list([ + dict({ + 'chart_ref': dict({ + 'kind': 'OCIRepository', + 'name': 'kyverno', + 'namespace': 'flux-system', + }), + 'name': 'kyverno', + 'namespace': 'kyverno', + }), dict({ 'chart': dict({ 'name': 'metallb', @@ -377,6 +395,11 @@ 'flux-system', 'weave-gitops', ), + tuple( + 'tests/testdata/cluster/infrastructure/controllers', + 'kyverno', + 'kyverno', + ), tuple( 'tests/testdata/cluster/infrastructure/controllers', 'metallb', @@ -461,6 +484,15 @@ 'config_maps': list([ ]), 'helm_releases': list([ + dict({ + 'chart_ref': dict({ + 'kind': 'OCIRepository', + 'name': 'kyverno', + 'namespace': 'flux-system', + }), + 'name': 'kyverno', + 'namespace': 'kyverno', + }), dict({ 'chart': dict({ 'name': 'metallb', @@ -591,6 +623,15 @@ 'config_maps': list([ ]), 'helm_releases': list([ + dict({ + 'chart_ref': dict({ + 'kind': 'OCIRepository', + 'name': 'kyverno', + 'namespace': 'flux-system', + }), + 'name': 'kyverno', + 'namespace': 'kyverno', + }), dict({ 'chart': dict({ 'name': 'metallb', @@ -1123,6 +1164,15 @@ 'config_maps': list([ ]), 'helm_releases': list([ + dict({ + 'chart_ref': dict({ + 'kind': 'OCIRepository', + 'name': 'kyverno', + 'namespace': 'flux-system', + }), + 'name': 'kyverno', + 'namespace': 'kyverno', + }), dict({ 'chart': dict({ 'name': 'metallb', diff --git a/tests/test_helm.py b/tests/test_helm.py index a450777c..5efb7f48 100644 --- a/tests/test_helm.py +++ b/tests/test_helm.py @@ -59,8 +59,9 @@ async def test_template(helm: Helm, helm_releases: list[dict[str, Any]]) -> None """Test helm template command.""" await helm.update() - assert len(helm_releases) == 2 - release = helm_releases[0] + assert len(helm_releases) == 3 + # metallb release, see tests/testdata/cluster/infrastructure/controllers/kustomization.yaml + release = helm_releases[1] obj = await helm.template(HelmRelease.parse_doc(release)) docs = await obj.grep("kind=ServiceAccount").objects() names = [doc.get("metadata", {}).get("name") for doc in docs] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index b930b215..7cabe69e 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -28,6 +28,7 @@ def test_parse_helm_release() -> None: ) assert release.name == "metallb" assert release.namespace == "metallb" + assert release.chart is not None assert release.chart.name == "metallb" assert release.chart.version == "4.1.14" assert release.chart.repo_name == "bitnami" diff --git a/tests/test_values.py b/tests/test_values.py index b5f797af..6494d9fe 100644 --- a/tests/test_values.py +++ b/tests/test_values.py @@ -54,6 +54,7 @@ def test_values_references_with_values_key() -> None: name="test-chart", version="test-version", ), + chart_ref=None, values={"test": "test"}, values_from=[ ValuesReference( @@ -112,6 +113,7 @@ def test_values_references_with_missing_values_key() -> None: name="test-chart", version="test-version", ), + chart_ref=None, values={"test": "test"}, values_from=[ ValuesReference( @@ -152,6 +154,7 @@ def test_values_references_with_missing_secret() -> None: name="test-chart", version="test-version", ), + chart_ref=None, values={"test": "test"}, values_from=[ ValuesReference( @@ -188,6 +191,7 @@ def test_values_references_with_missing_secret_values_key() -> None: name="test-chart", version="test-version", ), + chart_ref=None, values={"test": "test"}, values_from=[ ValuesReference( @@ -228,6 +232,7 @@ def test_values_references_invalid_yaml() -> None: name="test-chart", version="test-version", ), + chart_ref=None, values={"test": "test"}, values_from=[ ValuesReference( @@ -264,6 +269,7 @@ def test_values_references_invalid_binary_data() -> None: name="test-chart", version="test-version", ), + chart_ref=None, values={"test": "test"}, values_from=[ ValuesReference( @@ -304,6 +310,7 @@ def test_values_reference_invalid_target_path() -> None: "test": "test", "target": ["a", "b", "c"], }, + chart_ref=None, values_from=[ ValuesReference( kind="ConfigMap", @@ -342,6 +349,7 @@ def test_values_reference_invalid_configmap_and_secret() -> None: name="test-chart", version="test-version", ), + chart_ref=None, values={"test": "test"}, values_from=[ ValuesReference( @@ -383,6 +391,7 @@ def test_values_references_secret() -> None: name="test-chart", version="test-version", ), + chart_ref=None, values={"test": "test"}, values_from=[ ValuesReference( diff --git a/tests/testdata/cluster/infrastructure/configs/kustomization.yaml b/tests/testdata/cluster/infrastructure/configs/kustomization.yaml index bb6d201c..29d83b0e 100644 --- a/tests/testdata/cluster/infrastructure/configs/kustomization.yaml +++ b/tests/testdata/cluster/infrastructure/configs/kustomization.yaml @@ -4,3 +4,4 @@ kind: Kustomization resources: - cluster-policies.yaml - helm-repositories.yaml + - oci-repositories.yaml diff --git a/tests/testdata/cluster/infrastructure/configs/oci-repositories.yaml b/tests/testdata/cluster/infrastructure/configs/oci-repositories.yaml new file mode 100644 index 00000000..e0207a7b --- /dev/null +++ b/tests/testdata/cluster/infrastructure/configs/oci-repositories.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: OCIRepository +metadata: + name: kyverno + namespace: flux-system +spec: + interval: 1h + layerSelector: + mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + operation: copy + url: oci://ghcr.io/kyverno/charts/kyverno + ref: + tag: 3.2.3 + digest: sha256:d363081e45627aa396d6c8cb2d4ee59fcb7a79c223a967ae601c8c8ba4e7b7f3 diff --git a/tests/testdata/cluster/infrastructure/controllers/kustomization.yaml b/tests/testdata/cluster/infrastructure/controllers/kustomization.yaml index 6bb7dc20..12753f38 100644 --- a/tests/testdata/cluster/infrastructure/controllers/kustomization.yaml +++ b/tests/testdata/cluster/infrastructure/controllers/kustomization.yaml @@ -2,5 +2,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - kyverno-release.yaml - metallb-release.yaml - weave-gitops-release.yaml diff --git a/tests/testdata/cluster/infrastructure/controllers/kyverno-release.yaml b/tests/testdata/cluster/infrastructure/controllers/kyverno-release.yaml new file mode 100644 index 00000000..eb80df5d --- /dev/null +++ b/tests/testdata/cluster/infrastructure/controllers/kyverno-release.yaml @@ -0,0 +1,22 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: kyverno + namespace: kyverno +spec: + interval: 5m + chartRef: + kind: OCIRepository + name: kyverno + namespace: flux-system + driftDetection: + mode: enabled + install: + remediation: + retries: 3 + upgrade: + cleanupOnFail: true + remediation: + strategy: rollback + retries: 3 diff --git a/tests/tool/__snapshots__/test_build.ambr b/tests/tool/__snapshots__/test_build.ambr index a124c4b2..c46132e2 100644 --- a/tests/tool/__snapshots__/test_build.ambr +++ b/tests/tool/__snapshots__/test_build.ambr @@ -2988,8 +2988,57 @@ interval: 120m type: oci url: oci://ghcr.io/weaveworks/charts + --- + apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: OCIRepository + metadata: + labels: + kustomize.toolkit.fluxcd.io/name: infra-configs + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: kyverno + namespace: flux-system + annotations: + config.kubernetes.io/index: '4' + internal.config.kubernetes.io/index: '4' + spec: + interval: 1h + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + digest: sha256:d363081e45627aa396d6c8cb2d4ee59fcb7a79c223a967ae601c8c8ba4e7b7f3 + tag: 3.2.3 + url: oci://ghcr.io/kyverno/charts/kyverno --- + apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + metadata: + labels: + kustomize.toolkit.fluxcd.io/name: infra-controllers + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: kyverno + namespace: kyverno + annotations: + config.kubernetes.io/index: '0' + internal.config.kubernetes.io/index: '0' + spec: + chartRef: + kind: OCIRepository + name: kyverno + namespace: flux-system + driftDetection: + mode: enabled + install: + remediation: + retries: 3 + interval: 5m + upgrade: + cleanupOnFail: true + remediation: + retries: 3 + strategy: rollback + --- apiVersion: helm.toolkit.fluxcd.io/v2beta1 kind: HelmRelease metadata: @@ -2999,8 +3048,8 @@ name: metallb namespace: metallb annotations: - config.kubernetes.io/index: '0' - internal.config.kubernetes.io/index: '0' + config.kubernetes.io/index: '1' + internal.config.kubernetes.io/index: '1' spec: chart: spec: @@ -3032,8 +3081,8 @@ name: weave-gitops namespace: flux-system annotations: - config.kubernetes.io/index: '1' - internal.config.kubernetes.io/index: '1' + config.kubernetes.io/index: '2' + internal.config.kubernetes.io/index: '2' spec: chart: spec: @@ -4731,8 +4780,57 @@ interval: 120m type: oci url: oci://ghcr.io/weaveworks/charts + --- + apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: OCIRepository + metadata: + labels: + kustomize.toolkit.fluxcd.io/name: infra-configs + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: kyverno + namespace: flux-system + annotations: + config.kubernetes.io/index: '4' + internal.config.kubernetes.io/index: '4' + spec: + interval: 1h + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + digest: sha256:d363081e45627aa396d6c8cb2d4ee59fcb7a79c223a967ae601c8c8ba4e7b7f3 + tag: 3.2.3 + url: oci://ghcr.io/kyverno/charts/kyverno --- + apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + metadata: + labels: + kustomize.toolkit.fluxcd.io/name: infra-controllers + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: kyverno + namespace: kyverno + annotations: + config.kubernetes.io/index: '0' + internal.config.kubernetes.io/index: '0' + spec: + chartRef: + kind: OCIRepository + name: kyverno + namespace: flux-system + driftDetection: + mode: enabled + install: + remediation: + retries: 3 + interval: 5m + upgrade: + cleanupOnFail: true + remediation: + retries: 3 + strategy: rollback + --- apiVersion: helm.toolkit.fluxcd.io/v2beta1 kind: HelmRelease metadata: @@ -4742,8 +4840,8 @@ name: metallb namespace: metallb annotations: - config.kubernetes.io/index: '0' - internal.config.kubernetes.io/index: '0' + config.kubernetes.io/index: '1' + internal.config.kubernetes.io/index: '1' spec: chart: spec: @@ -4775,8 +4873,8 @@ name: weave-gitops namespace: flux-system annotations: - config.kubernetes.io/index: '1' - internal.config.kubernetes.io/index: '1' + config.kubernetes.io/index: '2' + internal.config.kubernetes.io/index: '2' spec: chart: spec: diff --git a/tests/tool/__snapshots__/test_get_cluster.ambr b/tests/tool/__snapshots__/test_get_cluster.ambr index 3970ae88..68c36d36 100644 --- a/tests/tool/__snapshots__/test_get_cluster.ambr +++ b/tests/tool/__snapshots__/test_get_cluster.ambr @@ -124,6 +124,12 @@ path: tests/testdata/cluster/infrastructure/controllers helm_repos: [] helm_releases: + - name: kyverno + namespace: kyverno + chart_ref: + kind: OCIRepository + name: kyverno + namespace: flux-system - name: metallb namespace: metallb chart: @@ -208,6 +214,12 @@ path: tests/testdata/cluster/infrastructure/controllers helm_repos: [] helm_releases: + - name: kyverno + namespace: kyverno + chart_ref: + kind: OCIRepository + name: kyverno + namespace: flux-system - name: metallb namespace: metallb chart: diff --git a/tests/tool/__snapshots__/test_get_ks.ambr b/tests/tool/__snapshots__/test_get_ks.ambr index 780dd937..71e1fd89 100644 --- a/tests/tool/__snapshots__/test_get_ks.ambr +++ b/tests/tool/__snapshots__/test_get_ks.ambr @@ -99,7 +99,7 @@ apps tests/testdata/cluster/apps/prod 0 1 flux-system tests/testdata/cluster/clusters/prod 0 0 infra-configs tests/testdata/cluster/infrastructure/configs 3 0 - infra-controllers tests/testdata/cluster/infrastructure/controllers 0 2 + infra-controllers tests/testdata/cluster/infrastructure/controllers 0 3 ''' # ---