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

Add support for chartRef in a HelmRelease #769

Merged
merged 1 commit into from
Aug 6, 2024
Merged
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
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
Loading