From 003678b959ca9624416c4c62818a72f89fefbc36 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 6 Aug 2024 04:24:42 +0000 Subject: [PATCH] Add support for chartRef in a HelmRelease --- flux_local/helm.py | 43 ++++- flux_local/manifest.py | 30 +++- flux_local/tool/build.py | 1 + flux_local/tool/test.py | 11 +- flux_local/tool/visitor.py | 10 +- tests/test_helm.py | 68 +++++++- tests/test_manifest.py | 23 +++ tests/testdata/cluster9/README.md | 2 + .../testdata/cluster9/apps/kustomization.yaml | 1 + .../cluster9/apps/podinfo/kustomization.yaml | 6 + .../cluster9/apps/podinfo/podinfo.yaml | 14 ++ .../cluster9/apps/podinfo/repository.yaml | 14 ++ tests/tool/__snapshots__/test_build.ambr | 162 ++++++++++++++++++ tests/tool/__snapshots__/test_get_hr.ambr | 5 +- 14 files changed, 362 insertions(+), 28 deletions(-) create mode 100644 tests/testdata/cluster9/apps/podinfo/kustomization.yaml create mode 100644 tests/testdata/cluster9/apps/podinfo/podinfo.yaml create mode 100644 tests/testdata/cluster9/apps/podinfo/repository.yaml diff --git a/flux_local/helm.py b/flux_local/helm.py index 5e4bc2ff..3f6513b9 100644 --- a/flux_local/helm.py +++ b/flux_local/helm.py @@ -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 @@ -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 @@ -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) @@ -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) @@ -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, diff --git a/flux_local/manifest.py b/flux_local/manifest.py index cf053d86..88e50915 100644 --- a/flux_local/manifest.py +++ b/flux_local/manifest.py @@ -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" @@ -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" @@ -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")): @@ -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")): diff --git a/flux_local/tool/build.py b/flux_local/tool/build.py index 14b9f597..5b702e33 100644 --- a/flux_local/tool/build.py +++ b/flux_local/tool/build.py @@ -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) diff --git a/flux_local/tool/test.py b/flux_local/tool/test.py index 358cc816..7e39f78e 100644 --- a/flux_local/tool/test.py +++ b/flux_local/tool/test.py @@ -26,6 +26,7 @@ Kustomization, HelmRelease, HelmRepository, + OCIRepository, ) from . import selector @@ -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): diff --git a/flux_local/tool/visitor.py b/flux_local/tool/visitor.py index 978af719..12934337 100644 --- a/flux_local/tool/visitor.py +++ b/flux_local/tool/visitor.py @@ -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 @@ -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) diff --git a/tests/test_helm.py b/tests/test_helm.py index a450777c..405cb540 100644 --- a/tests/test_helm.py +++ b/tests/test_helm.py @@ -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") @@ -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") @@ -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() @@ -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() @@ -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"] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index b930b215..c05b44a2 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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 diff --git a/tests/testdata/cluster9/README.md b/tests/testdata/cluster9/README.md index fc19a9d5..33fb178f 100644 --- a/tests/testdata/cluster9/README.md +++ b/tests/testdata/cluster9/README.md @@ -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`. diff --git a/tests/testdata/cluster9/apps/kustomization.yaml b/tests/testdata/cluster9/apps/kustomization.yaml index e69515b3..4ce46557 100644 --- a/tests/testdata/cluster9/apps/kustomization.yaml +++ b/tests/testdata/cluster9/apps/kustomization.yaml @@ -3,3 +3,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - nginx + - podinfo diff --git a/tests/testdata/cluster9/apps/podinfo/kustomization.yaml b/tests/testdata/cluster9/apps/podinfo/kustomization.yaml new file mode 100644 index 00000000..fcbd62fc --- /dev/null +++ b/tests/testdata/cluster9/apps/podinfo/kustomization.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - repository.yaml + - podinfo.yaml diff --git a/tests/testdata/cluster9/apps/podinfo/podinfo.yaml b/tests/testdata/cluster9/apps/podinfo/podinfo.yaml new file mode 100644 index 00000000..41e9ebf0 --- /dev/null +++ b/tests/testdata/cluster9/apps/podinfo/podinfo.yaml @@ -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 diff --git a/tests/testdata/cluster9/apps/podinfo/repository.yaml b/tests/testdata/cluster9/apps/podinfo/repository.yaml new file mode 100644 index 00000000..0c1b7410 --- /dev/null +++ b/tests/testdata/cluster9/apps/podinfo/repository.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: OCIRepository +metadata: + name: podinfo + namespace: default +spec: + interval: 10m + layerSelector: + mediaType: "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + operation: copy + url: oci://ghcr.io/stefanprodan/charts/podinfo + ref: + semver: ">= 6.0.0" diff --git a/tests/tool/__snapshots__/test_build.ambr b/tests/tool/__snapshots__/test_build.ambr index a124c4b2..fe2f2ef5 100644 --- a/tests/tool/__snapshots__/test_build.ambr +++ b/tests/tool/__snapshots__/test_build.ambr @@ -1667,6 +1667,46 @@ upgrade: remediation: retries: 3 + --- + apiVersion: source.toolkit.fluxcd.io/v1beta2 + kind: OCIRepository + metadata: + labels: + kustomize.toolkit.fluxcd.io/name: apps-stack + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: podinfo + namespace: default + annotations: + config.kubernetes.io/index: '1' + internal.config.kubernetes.io/index: '1' + spec: + interval: 10m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + ref: + semver: '>= 6.0.0' + url: oci://ghcr.io/stefanprodan/charts/podinfo + --- + apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + metadata: + labels: + kustomize.toolkit.fluxcd.io/name: apps-stack + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: podinfo + namespace: default + annotations: + config.kubernetes.io/index: '2' + internal.config.kubernetes.io/index: '2' + spec: + chartRef: + kind: OCIRepository + name: podinfo + namespace: default + interval: 10m + values: + replicaCount: 2 --- apiVersion: kustomize.toolkit.fluxcd.io/v1beta2 @@ -2669,6 +2709,128 @@ port: http resources: {} + --- + # Source: podinfo/templates/service.yaml + apiVersion: v1 + kind: Service + metadata: + name: podinfo + labels: + helm.sh/chart: podinfo-6.7.0 + app.kubernetes.io/name: podinfo + app.kubernetes.io/version: "6.7.0" + app.kubernetes.io/managed-by: Helm + annotations: + config.kubernetes.io/index: '0' + internal.config.kubernetes.io/index: '0' + spec: + type: ClusterIP + ports: + - port: 9898 + targetPort: http + protocol: TCP + name: http + - port: 9999 + targetPort: grpc + protocol: TCP + name: grpc + selector: + app.kubernetes.io/name: podinfo + --- + # Source: podinfo/templates/deployment.yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: podinfo + labels: + helm.sh/chart: podinfo-6.7.0 + app.kubernetes.io/name: podinfo + app.kubernetes.io/version: "6.7.0" + app.kubernetes.io/managed-by: Helm + annotations: + config.kubernetes.io/index: '1' + internal.config.kubernetes.io/index: '1' + spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: podinfo + template: + metadata: + labels: + app.kubernetes.io/name: podinfo + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9898" + spec: + terminationGracePeriodSeconds: 30 + containers: + - name: podinfo + image: "ghcr.io/stefanprodan/podinfo:6.7.0" + imagePullPolicy: IfNotPresent + command: + - ./podinfo + - --port=9898 + - --cert-path=/data/cert + - --port-metrics=9797 + - --grpc-port=9999 + - --grpc-service-name=podinfo + - --level=info + - --random-delay=false + - --random-error=false + env: + - name: PODINFO_UI_COLOR + value: "#34577c" + ports: + - name: http + containerPort: 9898 + protocol: TCP + - name: http-metrics + containerPort: 9797 + protocol: TCP + - name: grpc + containerPort: 9999 + protocol: TCP + livenessProbe: + exec: + command: + - podcli + - check + - http + - localhost:9898/healthz + initialDelaySeconds: 1 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + periodSeconds: 10 + readinessProbe: + exec: + command: + - podcli + - check + - http + - localhost:9898/readyz + initialDelaySeconds: 1 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + periodSeconds: 10 + volumeMounts: + - name: data + mountPath: /data + resources: + limits: null + requests: + cpu: 1m + memory: 16Mi + volumes: + - name: data + emptyDir: {} + ''' # --- diff --git a/tests/tool/__snapshots__/test_get_hr.ambr b/tests/tool/__snapshots__/test_get_hr.ambr index e8ebe726..c94bc1e7 100644 --- a/tests/tool/__snapshots__/test_get_hr.ambr +++ b/tests/tool/__snapshots__/test_get_hr.ambr @@ -58,8 +58,9 @@ # --- # name: test_get_hr[cluster9] ''' - NAMESPACE NAME REVISION CHART SOURCE - default nginx None default-./tests/testdata/cluster9/local-charts/nginx flux-system + NAMESPACE NAME REVISION CHART SOURCE + default nginx None default-./tests/testdata/cluster9/local-charts/nginx flux-system + default podinfo None default-podinfo podinfo ''' # ---