From 3da0d7de6bef62a8399f9497d383ef3ca7ba4001 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 5 Feb 2023 17:10:14 -0800 Subject: [PATCH] Add commands for evaluating HelmReleases (#12) Add commands for evaluating HelmReleases. The command can run `kustomize build` to locate `HelmRelease` and `HelmRepository` objects, then evaluate them with `helm template`. The resulting objects can also be filtered using `kustomize cfg grep`. --- .pre-commit-config.yaml | 19 ++- flux_local/helm.py | 135 +++++++++++++++++- flux_local/kustomize.py | 77 +++++++--- flux_local/manifest.py | 66 ++++++--- requirements.txt | 1 + script/run-in-env.sh | 22 +++ setup.cfg | 5 +- tests/test_command.py | 2 +- tests/test_helm.py | 71 +++++++++ tests/test_kustomize.py | 26 ++-- tests/test_manifest.py | 55 ++----- tests/testdata/helm-repo/kustomization.yaml | 6 + tests/testdata/helm-repo/metallb-release.yaml | 24 ++++ tests/testdata/helm-repo/sources.yaml | 11 ++ .../testdata/{repo.golden => repo/all.golden} | 0 .../configmap.golden} | 0 16 files changed, 411 insertions(+), 109 deletions(-) create mode 100755 script/run-in-env.sh create mode 100644 tests/test_helm.py create mode 100644 tests/testdata/helm-repo/kustomization.yaml create mode 100644 tests/testdata/helm-repo/metallb-release.yaml create mode 100644 tests/testdata/helm-repo/sources.yaml rename tests/testdata/{repo.golden => repo/all.golden} (100%) rename tests/testdata/{repo.configmap.golden => repo/configmap.golden} (100%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7bb1119c..3cb57896 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: - -c - ".yaml-lint.yaml" - repo: https://github.com/pycqa/isort - rev: 5.9.3 + rev: 5.12.0 hooks: - id: isort args: ["--profile", "black"] @@ -27,20 +27,19 @@ repos: rev: 22.3.0 hooks: - id: black -- repo: https://gitlab.com/pycqa/flake8 - rev: 4.0.1 +- repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 hooks: - id: flake8 -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.991 +- repo: local hooks: - id: mypy + name: mypy + entry: script/run-in-env.sh mypy + language: script + types: [python] + require_serial: true files: ^(flux_local/|tests/) - additional_dependencies: - - "pydantic>=1.10.4" - - "types-PyYAML>=6.0.12.4" -- repo: local - hooks: - id: pylint name: pylint entry: pylint diff --git a/flux_local/helm.py b/flux_local/helm.py index db977511..2d1dbc0c 100644 --- a/flux_local/helm.py +++ b/flux_local/helm.py @@ -1,9 +1,132 @@ -"""Library for running `helm template` to produce local items in the cluster.""" +"""Library for running `helm template` to produce local items in the cluster. -import subprocess +You can instantiate a helm template with the following: +- A HelmRepository which is a url that contains charts +- A HelmRelease which is an instance of a HelmChart in a HelmRepository +This is an example that prepares the helm repository: +``` +from flux_local.kustomize import Kustomize +from flux_local.helm import Helm +from flux_local.manifest import HelmRepository -def render(chart, values): - """Runs `helm template` to produce local items in the cluster.""" - cmd = ["helm", "template", chart, "--values", values] - return subprocess.check_output(cmd) +kustomize = Kustomize.build(TESTDATA_DIR) +repos = await kustomize.grep("kind=^HelmRepository$").objects() +helm = Helm("/tmp/path/helm", "/tmp/path/cache") +for repo in repos: + helm.add_repo(HelmRepository.from_doc(repo)) +await helm.update() +``` + +Then to actually instantiate a template from a HelmRelease: +``` +from flux_local.manifest import HelmRelease + +releases = await kustomize.grep("kind=^HelmRelease$").objects() +if not len(releases) == 1: + raise ValueError("Expected only one HelmRelease") +tmpl = helm.template( + HelmRelease.from_doc(releases[0]), + releases[0]["spec"].get("values")) +objects = await tmpl.objects() +for object in objects: + print(f"Found object {object['apiVersion']} {object['kind']}") +``` +""" + +import datetime +import logging +from pathlib import Path +from typing import Any + +import aiofiles +import yaml + +from . import command +from .kustomize import Kustomize +from .manifest import HelmRelease, HelmRepository + +__all__ = [ + "Helm", +] + +_LOGGER = logging.getLogger(__name__) + + +HELM_BIN = "helm" + + +class RepositoryConfig: + """Generates a helm repository configuration from flux HelmRepository objects.""" + + def __init__(self, repos: list[HelmRepository]) -> None: + """Initialize RepositoryConfig.""" + self._repos = repos + + @property + def config(self) -> dict[str, Any]: + """Return a synthetic repository config object.""" + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) + return { + "apiVersion": "", + "generated": now.isoformat(), + "repositories": [ + { + "name": f"{repo.namespace}-{repo.name}", + "url": repo.url, + } + for repo in self._repos + ], + } + + +class Helm: + """Manages local HelmRepository state.""" + + def __init__(self, tmp_dir: Path, cache_dir: Path) -> None: + """Initialize Helm.""" + self._tmp_dir = tmp_dir + self._repo_config_file = self._tmp_dir / "repository-config.yaml" + self._flags = [ + "--registry-config", + "/dev/null", + "--repository-cache", + str(cache_dir), + "--repository-config", + str(self._repo_config_file), + ] + self._repos: list[HelmRepository] = [] + + def add_repo(self, repo: HelmRepository) -> None: + """Add the specified HelmRepository to the local config.""" + self._repos.append(repo) + + async def update(self) -> None: + """Return command line arguments to update the local repo. + + Typically the repository must be updated before doing any chart templating. + """ + content = yaml.dump(RepositoryConfig(self._repos).config) + async with aiofiles.open(str(self._repo_config_file), mode="w") as config_file: + await config_file.write(content) + await command.run([HELM_BIN, "repo", "update"] + self._flags) + + async def template(self, release: HelmRelease, values: dict[str, Any]) -> Kustomize: + """Return command line arguments to template the specified chart.""" + args = [ + HELM_BIN, + "template", + release.name, + release.chart.chart_name, + "--namespace", + release.namespace, + "--skip-crds", # Reduce size of output + "--version", + release.chart.version, + ] + if values: + values_path = self._tmp_dir / f"{release.release_name}-values.yaml" + async with aiofiles.open(values_path, mode="w") as values_file: + await values_file.write(yaml.dump(values)) + args.extend(["--values", str(values_path)]) + return Kustomize([args + self._flags]) diff --git a/flux_local/kustomize.py b/flux_local/kustomize.py index 666db774..b8488098 100644 --- a/flux_local/kustomize.py +++ b/flux_local/kustomize.py @@ -3,30 +3,75 @@ Kustomize build can be used to apply overlays and output a set of resources or artifacts for the cluster that can be either be parsed directly or piped into additional commands for processing and filtering by kustomize grep. + +This example returns the objects inside a Kustomization using `kustomize build`: +``` +from flux_local.kustomize import Kustomize + +objects = await Kustomize.build('/path/to/objects').objects() +for object in objects: + print(f"Found object {object['apiVersion']} {object['kind']}") +``` + +You can also filter documents to specific resource types or other fields: +``` +from flux_local.kustomize import Kustomize + +objects = await Kustomize.build('/path/to/objects').grep('kind=ConfigMap').objects() +for object in objects: + print(f"Found ConfigMap: {object['metadata']['name']}") +``` + """ from pathlib import Path +from typing import Any, AsyncGenerator + +import yaml + +from . import command KUSTOMIZE_BIN = "kustomize" -def build(path: Path) -> list[str]: - """Generate a kustomize build command for the specified path.""" - return [KUSTOMIZE_BIN, "build", str(path)] +class Kustomize: + """Library for issuing a kustomize command.""" + + def __init__(self, cmds: list[list[str]]) -> None: + """Initialize Kustomize.""" + self._cmds = cmds + + @classmethod + def build(cls, path: Path) -> "Kustomize": + """Build cluster artifacts from the specified path.""" + return Kustomize(cmds=[[KUSTOMIZE_BIN, "build", str(path)]]) + + def grep( + self, expr: str, path: Path | None = None, invert: bool = False + ) -> "Kustomize": + """Filter resources based on an expression. + Example expressions: + `kind=HelmRelease` + `metadata.name=redis` + """ + out = [KUSTOMIZE_BIN, "cfg", "grep", expr] + if invert: + out.append("--invert-match") + if path: + out.append(str(path)) + return Kustomize(self._cmds + [out]) -def grep(expr: str, path: Path | None = None, invert: bool = False) -> list[str]: - """Generate a kustomize grep command to filter resources based on an expression. + async def run(self) -> str: + """Run the kustomize command and return the output as a string.""" + return await command.run_piped(self._cmds) - Example expressions: - `kind=HelmRelease` - `metadata.name=redis` + async def _docs(self) -> AsyncGenerator[dict[str, Any], None]: + """Run the kustomize command and return the result documents.""" + out = await self.run() + for doc in yaml.safe_load_all(out): + yield doc - The return value is a set of command args. - """ - out = [KUSTOMIZE_BIN, "cfg", "grep", expr] - if invert: - out.append("--invert-match") - if path: - out.append(str(path)) - return out + async def objects(self) -> list[dict[str, Any]]: + """Run the kustomize command and return the result cluster objects as a list.""" + return [doc async for doc in self._docs()] diff --git a/flux_local/manifest.py b/flux_local/manifest.py index 953dc8ec..218b8a19 100644 --- a/flux_local/manifest.py +++ b/flux_local/manifest.py @@ -17,18 +17,19 @@ "Kustomization", "HelmRepository", "HelmRelease", + "HelmChart", ] @dataclass -class HelmRelease: - """A representation of a Flux HelmRelease.""" +class HelmChart: + """A representation of an instantiation of a chart for a HelmRelease.""" name: str - """The name of the HelmRelease.""" + """The name of the chart within the HelmRepository.""" - namespace: str - """The namespace that owns the HelmRelease.""" + version: str + """The version of the chart.""" repo_name: str """The name of the HelmRepository.""" @@ -37,29 +38,58 @@ class HelmRelease: """The namespace of the HelmRepository.""" @classmethod - def from_doc(cls, doc: dict[str, Any]) -> "HelmRelease": - """Parse a HelmRelease from a kubernetes resource object.""" - if not (metadata := doc.get("metadata")): - raise ValueError(f"Invalid {cls} missing metadata: {doc}") - if not (name := metadata.get("name")): - raise ValueError(f"Invalid {cls} missing metadata.name: {doc}") - if not (namespace := metadata.get("namespace")): - raise ValueError(f"Invalid {cls} missing metadata.namespace: {doc}") + def from_doc(cls, doc: dict[str, Any]) -> "HelmChart": + """Parse a HelmChart from a HelmRelease resource object.""" if not (spec := doc.get("spec")): raise ValueError(f"Invalid {cls} missing spec: {doc}") if not (chart := spec.get("chart")): raise ValueError(f"Invalid {cls} missing spec.chart: {doc}") if not (chart_spec := chart.get("spec")): raise ValueError(f"Invalid {cls} missing spec.chart.spec: {doc}") + if not (chart := chart_spec.get("chart")): + raise ValueError(f"Invalid {cls} missing spec.chart.spec.chart: {doc}") + if not (version := chart_spec.get("version")): + raise ValueError(f"Invalid {cls} missing spec.chart.spec.version: {doc}") if not (source_ref := chart_spec.get("sourceRef")): raise ValueError(f"Invalid {cls} missing spec.chart.spec.sourceRef: {doc}") if "namespace" not in source_ref or "name" not in source_ref: raise ValueError(f"Invalid {cls} missing sourceRef fields: {doc}") - return cls(name, namespace, source_ref["name"], source_ref["namespace"]) + return cls(chart, version, source_ref["name"], source_ref["namespace"]) @property - def id_name(self) -> str: - """Identifier for the HelmRelease in tests.""" + def chart_name(self) -> str: + """Identifier for the HelmChart.""" + return f"{self.repo_namespace}-{self.repo_name}/{self.name}" + + +@dataclass +class HelmRelease: + """A representation of a Flux HelmRelease.""" + + name: str + """The name of the HelmRelease.""" + + namespace: str + """The namespace that owns the HelmRelease.""" + + chart: HelmChart + """A mapping to a specific helm chart for this HelmRelease.""" + + @classmethod + def from_doc(cls, doc: dict[str, Any]) -> "HelmRelease": + """Parse a HelmRelease from a kubernetes resource object.""" + if not (metadata := doc.get("metadata")): + raise ValueError(f"Invalid {cls} missing metadata: {doc}") + if not (name := metadata.get("name")): + raise ValueError(f"Invalid {cls} missing metadata.name: {doc}") + if not (namespace := metadata.get("namespace")): + raise ValueError(f"Invalid {cls} missing metadata.namespace: {doc}") + chart = HelmChart.from_doc(doc) + return cls(name, namespace, chart) + + @property + def release_name(self) -> str: + """Identifier for the HelmRelease.""" return f"{self.namespace}-{self.name}" @@ -92,8 +122,8 @@ def from_doc(cls, doc: dict[str, Any]) -> "HelmRepository": return cls(name, namespace, url) @property - def id_name(self) -> str: - """Identifier for the HelmRepository in tests.""" + def repo_name(self) -> str: + """Identifier for the HelmRepository.""" return f"{self.namespace}-{self.name}" diff --git a/requirements.txt b/requirements.txt index 25c20879..5d1e724b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ pytest-asyncio==0.20.3 pytest-cov==3.0.0 python-slugify==8.0.0 PyYAML==6.0 +types-aiofiles==22.1.0.6 types-PyYAML==6.0.12.4 typing-extensions==4.4.0 wheel==0.37.1 diff --git a/script/run-in-env.sh b/script/run-in-env.sh new file mode 100755 index 00000000..271e7a4a --- /dev/null +++ b/script/run-in-env.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -eu + +# Activate pyenv and virtualenv if present, then run the specified command + +# pyenv, pyenv-virtualenv +if [ -s .python-version ]; then + PYENV_VERSION=$(head -n 1 .python-version) + export PYENV_VERSION +fi + +# other common virtualenvs +my_path=$(git rev-parse --show-toplevel) + +for venv in venv .venv .; do + if [ -f "${my_path}/${venv}/bin/activate" ]; then + . "${my_path}/${venv}/bin/activate" + break + fi +done + +exec "$@" diff --git a/setup.cfg b/setup.cfg index 4c6a5f98..fc1a4d10 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,7 +20,8 @@ exclude = flux_local = py.typed [flake8] +exclude = venv max-line-length = 88 ignore = - E501, # black: Line too long - W503 # black: Line break before binary operator + E501, + W503 diff --git a/tests/test_command.py b/tests/test_command.py index ff4db8f4..1aa8e972 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,4 +1,4 @@ -"""Tests for cmd library.""" +"""Tests for command library.""" import pytest diff --git a/tests/test_helm.py b/tests/test_helm.py new file mode 100644 index 00000000..80821c4f --- /dev/null +++ b/tests/test_helm.py @@ -0,0 +1,71 @@ +"""Tests for helm library.""" + +from aiofiles.os import mkdir +from pathlib import Path +from typing import Any, Generator +import yaml + +import pytest + +from flux_local.manifest import HelmRepository +from flux_local.manifest import HelmRelease +from flux_local.kustomize import Kustomize +from flux_local.helm import Helm + +TESTDATA_DIR = Path("tests/testdata/") / "helm-repo" + +@pytest.fixture(name="tmp_config_path") +def tmp_config_path_fixture(tmp_path_factory: Any) -> Generator[Path, None, None]: + """Fixture for creating a path used for helm config shared across tests.""" + yield tmp_path_factory.mktemp("test_helm") + + +@pytest.fixture(name="helm_repos") +async def helm_repos_fixture() -> list[HelmRepository]: + """Fixture for creating the HelmRepository objects""" + kustomize = Kustomize.build(TESTDATA_DIR).grep("kind=^HelmRepository$") + return await kustomize.objects() + + +@pytest.fixture(name="helm") +async def helm_fixture(tmp_config_path: Path, helm_repos: list[dict[str, any]]) -> Helm: + """Fixture for creating the Helm object.""" + await mkdir(tmp_config_path / "helm") + await mkdir(tmp_config_path / "cache") + helm = Helm( + tmp_config_path / "helm", + tmp_config_path / "cache", + ) + for repo in helm_repos: + helm.add_repo(HelmRepository.from_doc(repo)) + return helm + + +@pytest.fixture(name="helm_releases") +async def helm_releases_fixture() -> list[dict[str, Any]]: + """Fixture for creating the HelmRelease objects.""" + kustomize = Kustomize.build(TESTDATA_DIR).grep("kind=^HelmRelease$") + return await kustomize.objects() + + +async def test_update(helm: Helm) -> None: + """Test a helm update command.""" + await helm.update() + + +async def test_template(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] + kustomize = await helm.template( + HelmRelease.from_doc(release), + release["spec"].get("values") + ) + docs = await kustomize.grep("kind=ServiceAccount").objects() + names = [ doc.get("metadata", {}).get("name") for doc in docs ] + assert names == [ + 'metallb-controller', + 'metallb-speaker' + ] diff --git a/tests/test_kustomize.py b/tests/test_kustomize.py index e53e77b5..52f59813 100644 --- a/tests/test_kustomize.py +++ b/tests/test_kustomize.py @@ -1,28 +1,32 @@ -"""Tests for cmd library.""" +"""Tests for kustomize library.""" from pathlib import Path -from flux_local import command, kustomize +from flux_local.kustomize import Kustomize TESTDATA_DIR = Path("tests/testdata") async def test_build() -> None: """Test a kustomize build command.""" - result = await command.run(kustomize.build(TESTDATA_DIR / "repo")) + result = await Kustomize.build(TESTDATA_DIR / "repo").run() assert "Secret" in result assert "ConfigMap" in result - assert result == (TESTDATA_DIR / "repo.golden").read_text() + assert result == (TESTDATA_DIR / "repo/all.golden").read_text() async def test_grep() -> None: """Test a kustomize build command.""" - result = await command.run_piped( - [ - kustomize.build(TESTDATA_DIR / "repo"), - kustomize.grep("kind=ConfigMap"), - ] - ) + result = await Kustomize.build(TESTDATA_DIR / "repo").grep("kind=ConfigMap").run() assert "Secret" not in result assert "ConfigMap" in result - assert result == (TESTDATA_DIR / "repo.configmap.golden").read_text() + assert result == (TESTDATA_DIR / "repo/configmap.golden").read_text() + + +async def test_objects() -> None: + """Test loading yaml documents.""" + kustomize = Kustomize.build(TESTDATA_DIR / "repo").grep("kind=ConfigMap") + result = await kustomize.objects() + assert len(result) == 1 + assert result[0].get("kind") == "ConfigMap" + assert result[0].get("apiVersion") == "v1" diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 66c45428..eea92e0c 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -1,68 +1,33 @@ """Tests for manifest library.""" import yaml +from pathlib import Path from flux_local.manifest import HelmRelease, HelmRepository +TESTDATA_DIR = Path("tests/testdata/helm-repo") + + def test_parse_helm_release() -> None: """Test parsing a helm release doc.""" release = HelmRelease.from_doc( - yaml.load( - """--- -apiVersion: helm.toolkit.fluxcd.io/v2beta1 -kind: HelmRelease -metadata: - name: metallb - namespace: metallb -spec: - chart: - spec: - chart: metallb - reconcileStrategy: ChartVersion - sourceRef: - kind: HelmRepository - name: bitnami - namespace: flux-system - version: 4.1.14 - install: - crds: CreateReplace - remediation: - retries: 3 - interval: 5m - releaseName: metallb - upgrade: - crds: CreateReplace -""", - Loader=yaml.CLoader, - ) + yaml.load((TESTDATA_DIR / "metallb-release.yaml").read_text(), Loader=yaml.CLoader) ) assert release.name == "metallb" assert release.namespace == "metallb" - assert release.repo_name == "bitnami" - assert release.repo_namespace == "flux-system" + assert release.chart.name == "metallb" + assert release.chart.version == "4.1.14" + assert release.chart.repo_name == "bitnami" + assert release.chart.repo_namespace == "flux-system" def test_parse_helm_repository() -> None: """Test parsing a helm repository doc.""" repo = HelmRepository.from_doc( - yaml.load( - """--- -apiVersion: source.toolkit.fluxcd.io/v1beta2 -kind: HelmRepository -metadata: - name: bitnami - namespace: flux-system -spec: - interval: 30m - provider: generic - timeout: 1m0s - url: https://charts.bitnami.com/bitnami -""", - Loader=yaml.CLoader, - ) + yaml.load((TESTDATA_DIR / "sources.yaml").read_text(), Loader=yaml.CLoader) ) assert repo.name == "bitnami" assert repo.namespace == "flux-system" diff --git a/tests/testdata/helm-repo/kustomization.yaml b/tests/testdata/helm-repo/kustomization.yaml new file mode 100644 index 00000000..0e5f29aa --- /dev/null +++ b/tests/testdata/helm-repo/kustomization.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - metallb-release.yaml + - sources.yaml diff --git a/tests/testdata/helm-repo/metallb-release.yaml b/tests/testdata/helm-repo/metallb-release.yaml new file mode 100644 index 00000000..3cd7b14c --- /dev/null +++ b/tests/testdata/helm-repo/metallb-release.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + name: metallb + namespace: metallb +spec: + chart: + spec: + chart: metallb + reconcileStrategy: ChartVersion + sourceRef: + kind: HelmRepository + name: bitnami + namespace: flux-system + version: 4.1.14 + install: + crds: CreateReplace + remediation: + retries: 3 + interval: 5m + releaseName: metallb + upgrade: + crds: CreateReplace diff --git a/tests/testdata/helm-repo/sources.yaml b/tests/testdata/helm-repo/sources.yaml new file mode 100644 index 00000000..3320a100 --- /dev/null +++ b/tests/testdata/helm-repo/sources.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmRepository +metadata: + name: bitnami + namespace: flux-system +spec: + interval: 30m + provider: generic + timeout: 1m0s + url: https://charts.bitnami.com/bitnami diff --git a/tests/testdata/repo.golden b/tests/testdata/repo/all.golden similarity index 100% rename from tests/testdata/repo.golden rename to tests/testdata/repo/all.golden diff --git a/tests/testdata/repo.configmap.golden b/tests/testdata/repo/configmap.golden similarity index 100% rename from tests/testdata/repo.configmap.golden rename to tests/testdata/repo/configmap.golden