Skip to content

Commit

Permalink
feat: Parse HelmRelease with chartRef
Browse files Browse the repository at this point in the history
This patch adds minimal support for parsing HelmRelease objects with
a `chartRef` field instead of a `chart`. There is no support for
processing such releases in any way, but at least flux-local won't abort
during parsing.
  • Loading branch information
jfroy committed Jun 30, 2024
1 parent 5409e97 commit c015202
Show file tree
Hide file tree
Showing 16 changed files with 309 additions and 33 deletions.
4 changes: 4 additions & 0 deletions flux_local/helm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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(
Expand Down
80 changes: 65 additions & 15 deletions flux_local/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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."""
Expand Down Expand Up @@ -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."""

Expand All @@ -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"):
Expand All @@ -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,
)
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions flux_local/tool/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions flux_local/tool/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 5 additions & 0 deletions flux_local/tool/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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,
Expand All @@ -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)
50 changes: 50 additions & 0 deletions tests/__snapshots__/test_git_repo.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -377,6 +395,11 @@
'flux-system',
'weave-gitops',
),
tuple(
'tests/testdata/cluster/infrastructure/controllers',
'kyverno',
'kyverno',
),
tuple(
'tests/testdata/cluster/infrastructure/controllers',
'metallb',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions tests/test_helm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading

0 comments on commit c015202

Please sign in to comment.