Skip to content

Commit

Permalink
Add support for chartRef in a HelmRelease
Browse files Browse the repository at this point in the history
  • Loading branch information
allenporter committed Aug 6, 2024
1 parent fa82f5a commit 003678b
Show file tree
Hide file tree
Showing 14 changed files with 362 additions and 28 deletions.
43 changes: 35 additions & 8 deletions flux_local/helm.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@
from .manifest import (
HelmRelease,
HelmRepository,
OCIRepository,
CRD_KIND,
SECRET_KIND,
REPO_TYPE_OCI,
HELM_REPOSITORY,
GIT_REPOSITORY,
OCI_REPOSITORY,
)
from .exceptions import HelmException

Expand All @@ -66,9 +68,23 @@
DEFAULT_REGISTRY_CONFIG = "/dev/null"


def _chart_name(release: HelmRelease, repo: HelmRepository | None) -> str:
def _chart_name(
release: HelmRelease, repo: HelmRepository | OCIRepository | None
) -> str:
"""Return the helm chart name used for the helm template command."""
if release.chart.repo_kind == OCI_REPOSITORY:
assert repo
if isinstance(repo, OCIRepository):
return repo.url
raise HelmException(
f"HelmRelease {release.name} expected OCIRepository but got HelmRepository {repo.repo_name}"
)
if release.chart.repo_kind == HELM_REPOSITORY:
assert repo
if not isinstance(repo, HelmRepository):
raise HelmException(
f"HelmRelease {release.name} expected HelmRepository but got OCIRepository {repo.repo_name}"
)
if repo and repo.repo_type == REPO_TYPE_OCI:
return f"{repo.url}/{release.chart.name}"
return release.chart.chart_name
Expand Down Expand Up @@ -173,13 +189,20 @@ def __init__(self, tmp_dir: Path, cache_dir: Path) -> None:
"--repository-config",
str(self._repo_config_file),
]
self._repos: list[HelmRepository] = []
self._repos: list[HelmRepository | OCIRepository] = []

def add_repo(self, repo: HelmRepository) -> None:
def add_repo(self, repo: HelmRepository | OCIRepository) -> None:
"""Add the specified HelmRepository to the local config."""
self._repos.append(repo)

def add_repos(self, repos: list[HelmRepository]) -> None:
def add_repos(
self,
repos: (
list[HelmRepository]
| list[OCIRepository]
| list[HelmRepository | OCIRepository]
),
) -> None:
"""Add the specified HelmRepository to the local config."""
for repo in repos:
self._repos.append(repo)
Expand All @@ -190,7 +213,11 @@ async def update(self) -> None:
Typically the repository must be updated before doing any chart templating.
"""
_LOGGER.debug("Updating %d repositories", len(self._repos))
repos = [repo for repo in self._repos if repo.repo_type != REPO_TYPE_OCI]
repos = [
repo
for repo in self._repos
if isinstance(repo, HelmRepository) and repo.repo_type != REPO_TYPE_OCI
]
if not repos:
return
content = yaml.dump(RepositoryConfig(repos).config, sort_keys=False)
Expand Down Expand Up @@ -218,11 +245,11 @@ async def template(
)
# We'll attempt to make a chart name for a GitRepository below and it will
# be somewhat best effort.
if not repo and release.chart.repo_kind == HELM_REPOSITORY:
if not repo and release.chart.repo_kind != GIT_REPOSITORY:
raise HelmException(
f"Unable to find HelmRepository for {release.chart.chart_name} for "
f"Unable to find HelmRepository or OCIRepository for {release.chart.chart_name} for "
f"HelmRelease {release.name} "
f"({len(self._repos)} other HelmRepositories in --path)"
f"({len(self._repos)} other HelmRepositories/OCIRepositories in --path)"
)
args: list[str] = [
HELM_BIN,
Expand Down
30 changes: 26 additions & 4 deletions flux_local/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
KUSTOMIZE_DOMAIN = "kustomize.config.k8s.io"
HELM_REPO_DOMAIN = "source.toolkit.fluxcd.io"
HELM_RELEASE_DOMAIN = "helm.toolkit.fluxcd.io"
OCI_REPOSITORY_DOMAIN = "source.toolkit.fluxcd.io"
CLUSTER_POLICY_DOMAIN = "kyverno.io"
CRD_KIND = "CustomResourceDefinition"
SECRET_KIND = "Secret"
Expand All @@ -50,7 +51,8 @@
VALUE_B64_PLACEHOLDER = base64.b64encode(VALUE_PLACEHOLDER.encode())
HELM_REPOSITORY = "HelmRepository"
GIT_REPOSITORY = "GitRepository"
GIT_REPOSITORY_DOMAIN = "source.toolkit.fluxcd.io"
OCI_REPOSITORY = "OCIRepository"


REPO_TYPE_DEFAULT = "default"
REPO_TYPE_OCI = "oci"
Expand Down Expand Up @@ -114,8 +116,28 @@ def parse_doc(cls, doc: dict[str, Any], default_namespace: str) -> "HelmChart":
_check_version(doc, HELM_RELEASE_DOMAIN)
if not (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}")
chart_ref = spec.get("chartRef")
chart = spec.get("chart")
if not chart_ref and not chart:
raise InputException(
f"Invalid {cls} missing spec.chart or spec.chartRef: {doc}"
)
if chart_ref:
if not (kind := chart_ref.get("kind")):
raise InputException(f"Invalid {cls} missing spec.chartRef.kind: {doc}")
if not (name := chart_ref.get("name")):
raise InputException(f"Invalid {cls} missing spec.chartRef.name: {doc}")
if not (namespace := chart_ref.get("namespace")):
raise InputException(
f"Invalid {cls} missing spec.chartRef.namespace: {doc}"
)
return cls(
name=name,
version=None,
repo_name=name,
repo_namespace=namespace,
repo_kind=kind,
)
if not (chart_spec := chart.get("spec")):
raise InputException(f"Invalid {cls} missing spec.chart.spec: {doc}")
if not (chart := chart_spec.get("chart")):
Expand Down Expand Up @@ -292,7 +314,7 @@ class OCIRepository(BaseManifest):
@classmethod
def parse_doc(cls, doc: dict[str, Any]) -> "OCIRepository":
"""Parse a HelmRepository from a kubernetes resource."""
_check_version(doc, GIT_REPOSITORY_DOMAIN)
_check_version(doc, OCI_REPOSITORY_DOMAIN)
if not (metadata := doc.get("metadata")):
raise InputException(f"Invalid {cls} missing metadata: {doc}")
if not (name := metadata.get("name")):
Expand Down
1 change: 1 addition & 0 deletions flux_local/tool/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ async def run( # type: ignore[no-untyped-def]
query.kustomization.visitor = content.visitor()
helm_visitor = HelmVisitor()
query.helm_repo.visitor = helm_visitor.repo_visitor()
query.oci_repo.visitor = helm_visitor.repo_visitor()
query.helm_release.visitor = helm_visitor.release_visitor()
await git_repo.build_manifest(
selector=query, options=selector.options(**kwargs)
Expand Down
11 changes: 8 additions & 3 deletions flux_local/tool/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
Kustomization,
HelmRelease,
HelmRepository,
OCIRepository,
)
from . import selector

Expand Down Expand Up @@ -95,15 +96,19 @@ async def async_runtest(self) -> None:
await cmd.objects()
await cmd.validate_policies(self.cluster.cluster_policies)

def active_repos(self) -> list[HelmRepository]:
"""Return HelpRepositories referenced by a HelmRelease."""
def active_repos(self) -> list[HelmRepository | OCIRepository]:
"""Return HelmRepositories referenced by a HelmRelease."""
repo_key = "-".join(
[
self.helm_release.chart.repo_namespace,
self.helm_release.chart.repo_name,
]
)
return [repo for repo in self.cluster.helm_repos if repo.repo_name == repo_key]
return [
repo
for repo in self.cluster.helm_repos + self.cluster.oci_repos
if repo.repo_name == repo_key
]


class KustomizationTest(pytest.Item):
Expand Down
10 changes: 6 additions & 4 deletions flux_local/tool/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,11 @@ class HelmVisitor:

def __init__(self) -> None:
"""Initialize KustomizationContentOutput."""
self.repos: list[HelmRepository] = []
self.repos: list[HelmRepository | OCIRepository] = []
self.releases: list[HelmRelease] = []

@property
def active_repos(self) -> list[HelmRepository]:
def active_repos(self) -> list[HelmRepository | OCIRepository]:
"""Return HelpRepositories referenced by a HelmRelease."""
repo_keys: set[str] = {
release.chart.repo_full_name for release in self.releases
Expand All @@ -264,8 +264,10 @@ async def add_repo(
doc: ResourceType,
cmd: Kustomize | None,
) -> None:
if not isinstance(doc, HelmRepository):
raise ValueError(f"Expected HelmRepository: {doc}")
if not isinstance(doc, HelmRepository) and not isinstance(
doc, OCIRepository
):
raise ValueError(f"Expected HelmRepository or OCIRepository: {doc}")
self.repos.append(doc)

return git_repo.ResourceVisitor(func=add_repo)
Expand Down
68 changes: 61 additions & 7 deletions tests/test_helm.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@
from flux_local.manifest import (
HelmRelease,
HelmRepository,
OCIRepository,
)

REPO_DIR = Path("tests/testdata/cluster/infrastructure/configs")
RELEASE_DIR = Path("tests/testdata/cluster/infrastructure/controllers")

@pytest.fixture(name="helm_repo_dir")
def helm_repo_dir_fixture() -> Path | None:
return None


@pytest.fixture(name="oci_repo_dir")
def oci_repo_dir_fixture() -> Path | None:
return None


@pytest.fixture(name="tmp_config_path")
Expand All @@ -24,14 +32,29 @@ def tmp_config_path_fixture(tmp_path_factory: Any) -> Generator[Path, None, None


@pytest.fixture(name="helm_repos")
async def helm_repos_fixture() -> list[dict[str, Any]]:
async def helm_repos_fixture(helm_repo_dir: Path | None) -> list[dict[str, Any]]:
"""Fixture for creating the HelmRepository objects"""
cmd = kustomize.grep("kind=^HelmRepository$", REPO_DIR)
if not helm_repo_dir:
return []
cmd = kustomize.grep("kind=^HelmRepository$", helm_repo_dir)
return await cmd.objects()


@pytest.fixture(name="oci_repos")
async def oci_repos_fixture(oci_repo_dir: Path) -> list[dict[str, Any]]:
"""Fixture for creating the OCIRepositoriy objects"""
if not oci_repo_dir:
return []
cmd = kustomize.grep("kind=^OCIRepository$", oci_repo_dir)
return await cmd.objects()


@pytest.fixture(name="helm")
async def helm_fixture(tmp_config_path: Path, helm_repos: list[dict[str, Any]]) -> Helm:
async def helm_fixture(
tmp_config_path: Path,
helm_repos: list[dict[str, Any]],
oci_repos: list[dict[str, Any]],
) -> Helm:
"""Fixture for creating the Helm object."""
await mkdir(tmp_config_path / "helm")
await mkdir(tmp_config_path / "cache")
Expand All @@ -40,13 +63,14 @@ async def helm_fixture(tmp_config_path: Path, helm_repos: list[dict[str, Any]])
tmp_config_path / "cache",
)
helm.add_repos([HelmRepository.parse_doc(repo) for repo in helm_repos])
helm.add_repos([OCIRepository.parse_doc(repo) for repo in oci_repos])
return helm


@pytest.fixture(name="helm_releases")
async def helm_releases_fixture() -> list[dict[str, Any]]:
async def helm_releases_fixture(release_dir: Path) -> list[dict[str, Any]]:
"""Fixture for creating the HelmRelease objects."""
cmd = kustomize.grep("kind=^HelmRelease$", RELEASE_DIR)
cmd = kustomize.grep("kind=^HelmRelease$", release_dir)
return await cmd.objects()


Expand All @@ -55,6 +79,15 @@ async def test_update(helm: Helm) -> None:
await helm.update()


@pytest.mark.parametrize(
("helm_repo_dir", "release_dir"),
[
(
Path("tests/testdata/cluster/infrastructure/configs"),
Path("tests/testdata/cluster/infrastructure/controllers"),
),
],
)
async def test_template(helm: Helm, helm_releases: list[dict[str, Any]]) -> None:
"""Test helm template command."""
await helm.update()
Expand All @@ -65,3 +98,24 @@ async def test_template(helm: Helm, helm_releases: list[dict[str, Any]]) -> None
docs = await obj.grep("kind=ServiceAccount").objects()
names = [doc.get("metadata", {}).get("name") for doc in docs]
assert names == ["metallb-controller", "metallb-speaker"]


@pytest.mark.parametrize(
("oci_repo_dir", "release_dir"),
[
(
Path("tests/testdata/cluster9/apps/podinfo/"),
Path("tests/testdata/cluster9/apps/podinfo/"),
),
],
)
async def test_oci_repository(helm: Helm, helm_releases: list[dict[str, Any]]) -> None:
"""Test helm template command."""
await helm.update()

assert len(helm_releases) == 1
release = helm_releases[0]
obj = await helm.template(HelmRelease.parse_doc(release))
docs = await obj.grep("kind=Deployment").objects()
names = [doc.get("metadata", {}).get("name") for doc in docs]
assert names == ["podinfo"]
23 changes: 23 additions & 0 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,26 @@ async def test_serializing_manifest(tmp_path: Path) -> None:
},
]
}


def test_parse_helmrelease_chartref() -> None:
"""Test parsing a helm release doc."""

HELM_CHARTREF_FILE = Path("tests/testdata/cluster9/apps/podinfo/podinfo.yaml")
docs = list(
yaml.load_all(
HELM_CHARTREF_FILE.read_text(),
Loader=yaml.CLoader,
)
)
assert len(docs) == 1
assert docs[0].get("kind") == "HelmRelease"

release = HelmRelease.parse_doc(docs[0])
assert release.name == "podinfo"
assert release.namespace == "default"
assert release.chart.name == "podinfo"
assert release.chart.version is None
assert release.chart.repo_name == "podinfo"
assert release.chart.repo_namespace == "default"
assert release.values
2 changes: 2 additions & 0 deletions tests/testdata/cluster9/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@

Cluster from https://github.com/tropnikovvl/flux-local-test that has a local
HelmChart in the GitRepository.

Extended with an additional `chartRef` of a remote `OCIRepository`.
1 change: 1 addition & 0 deletions tests/testdata/cluster9/apps/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- nginx
- podinfo
6 changes: 6 additions & 0 deletions tests/testdata/cluster9/apps/podinfo/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- repository.yaml
- podinfo.yaml
14 changes: 14 additions & 0 deletions tests/testdata/cluster9/apps/podinfo/podinfo.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: podinfo
namespace: default
spec:
interval: 10m
chartRef:
kind: OCIRepository
name: podinfo
namespace: default
values:
replicaCount: 2
Loading

0 comments on commit 003678b

Please sign in to comment.