Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Parse HelmRelease with chartRef #709

Closed
wants to merge 1 commit into from
Closed
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
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
Loading