├── .github ├── FUNDING.yml └── pr-labeler.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pre-commit-hooks.yaml ├── LICENSE ├── README.md ├── pre_commit_flux ├── __init__.py └── check_flux_helm_values.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── default │ ├── kustomization.yaml │ ├── release.yaml │ └── repository.yaml ├── invalid_kustomization │ ├── kustomization.yaml │ └── release.yaml ├── kustomization │ ├── kustomization.yaml │ └── release.yaml └── test_precommit.py └── tox.ini /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tarioch] 2 | -------------------------------------------------------------------------------- /.github/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | name: PR Labeler 2 | on: 3 | pull_request: 4 | types: [opened] 5 | 6 | jobs: 7 | pr-labeler: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: TimonVS/pr-labeler-action@v3 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .spyproject 2 | __pycache__ 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-added-large-files 7 | - id: check-ast 8 | - id: check-json 9 | - id: check-merge-conflict 10 | - id: check-xml 11 | - id: check-yaml 12 | - id: debug-statements 13 | - id: end-of-file-fixer 14 | - id: requirements-txt-fixer 15 | - id: mixed-line-ending 16 | args: ['--fix=auto'] # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows 17 | 18 | - repo: https://github.com/pycqa/isort 19 | rev: 5.11.4 20 | hooks: 21 | - id: isort 22 | 23 | - repo: https://github.com/psf/black 24 | rev: 22.12.0 25 | hooks: 26 | - id: black 27 | language_version: python3 28 | 29 | - repo: https://github.com/pycqa/flake8 30 | rev: 6.0.0 31 | hooks: 32 | - id: flake8 33 | ## You can add flake8 plugins via `additional_dependencies`: 34 | # additional_dependencies: [flake8-bugbear] 35 | 36 | - repo: https://github.com/pre-commit/mirrors-mypy 37 | rev: v0.991 38 | hooks: 39 | - id: mypy 40 | args: [--install-types, --non-interactive, --ignore-missing-imports] 41 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: check-flux-helm-values 2 | name: Check flux helm values 3 | description: "Verify that values used by flux HelmReleases are ok." 4 | entry: check-flux-helm-values 5 | language: python 6 | files: ^[^.].*\.yaml$ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Patrick Ruckstuhl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [pre-commit](http://pre-commit.com) hook for working with [flux](http://fluxcd.io) 2 | 3 | 4 | ## Usage 5 | 6 | ``` 7 | - repo: https://github.com/tarioch/flux-check-hook 8 | rev: v0.5.0 9 | hooks: 10 | - id: check-flux-helm-values 11 | ``` 12 | 13 | The hook depends on the helm binary being available in the path (but it doesn't require to be able to connect to a cluster). 14 | -------------------------------------------------------------------------------- /pre_commit_flux/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarioch/flux-check-hook/dba12e714f900e2b99a0c25be7e5f87b0482cf5a/pre_commit_flux/__init__.py -------------------------------------------------------------------------------- /pre_commit_flux/check_flux_helm_values.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os.path as path 3 | import subprocess 4 | import sys 5 | import tempfile 6 | from shlex import quote 7 | 8 | import yaml 9 | 10 | errors: list = [] 11 | 12 | 13 | def main(): 14 | repos = _buildRepoMap() 15 | for arg in sys.argv[1:]: 16 | try: 17 | _validateFile(arg, repos) 18 | except Exception as ex: 19 | _collectErrors({"source": arg, "message": f"{type(ex).__name__} {ex.args}"}) 20 | if len(errors) > 0: 21 | _printErrors() 22 | exit(1) 23 | 24 | 25 | def _buildRepoMap(): 26 | repos = {} 27 | for file in glob.glob("./**/*.yaml", recursive=True): 28 | with open(file) as f: 29 | try: 30 | for definition in yaml.load_all(f, Loader=yaml.SafeLoader): 31 | if ( 32 | not definition 33 | or "kind" not in definition 34 | or definition["kind"] != "HelmRepository" 35 | ): 36 | continue 37 | repoName = definition["metadata"]["name"] 38 | repos[repoName] = definition["spec"]["url"] 39 | except Exception: 40 | continue 41 | 42 | return repos 43 | 44 | def check_kustomiztion(path: str): 45 | kustomize_release = {} 46 | command = f'kubectl kustomize {path}' 47 | res = subprocess.run( 48 | command, 49 | shell=True, 50 | text=True, 51 | stdout=subprocess.PIPE, 52 | stderr=subprocess.STDOUT, 53 | ) 54 | if res.returncode == 0: 55 | doc = str(res.stdout) 56 | for definition in yaml.load_all(doc, Loader=yaml.SafeLoader): 57 | if ( 58 | definition and "kind" in definition 59 | and definition["kind"] == "HelmRelease" 60 | ): 61 | kustomize_release = definition 62 | 63 | return kustomize_release 64 | 65 | def _validateFile(fileToValidate, repos): 66 | with open(fileToValidate) as f: 67 | for definition in yaml.load_all(f, Loader=yaml.SafeLoader): 68 | if ( 69 | not definition 70 | or "kind" not in definition 71 | or definition["kind"] != "HelmRelease" 72 | ): 73 | continue 74 | 75 | try: 76 | chartSpec = definition["spec"]["chart"]["spec"] 77 | 78 | except KeyError: 79 | # Maybe it kustomize 80 | path_to_file = f.name.split('/') 81 | while path_to_file: 82 | path_to_file.pop() 83 | fileDir = '/'.join(path_to_file) 84 | check = check_kustomiztion(fileDir) 85 | if check: 86 | print(f'kustomization for {f.name} found {fileDir}') 87 | definition = check 88 | break 89 | 90 | try: 91 | chartSpec = definition["spec"]["chart"]["spec"] 92 | 93 | except KeyError as e: 94 | if definition["spec"]["chartRef"]: 95 | print("Cannot validate OCI-based charts, skipping") 96 | continue 97 | else: 98 | raise e 99 | 100 | if chartSpec["sourceRef"]["kind"] != "HelmRepository": 101 | continue 102 | 103 | chartName = chartSpec["chart"] 104 | chartVersion = chartSpec["version"] 105 | chartUrl = repos[chartSpec["sourceRef"]["name"]] 106 | 107 | with tempfile.TemporaryDirectory() as tmpDir: 108 | with open(path.join(tmpDir, "values.yaml"), "w") as valuesFile: 109 | if "spec" in definition and "values" in definition["spec"]: 110 | yaml.dump(definition["spec"]["values"], valuesFile) 111 | 112 | if chartUrl.startswith("oci://"): 113 | chartOciUrl = f"{chartUrl}{'' if chartUrl.endswith('/') else '/'}{chartName}" 114 | command = f"helm pull {quote(chartOciUrl)} --version {quote(chartVersion)}" 115 | else: 116 | command = f"helm pull --repo {quote(chartUrl)} --version {quote(chartVersion)} {quote(chartName)}" 117 | 118 | res = subprocess.run( 119 | command, 120 | shell=True, 121 | cwd=tmpDir, 122 | text=True, 123 | stdout=subprocess.PIPE, 124 | stderr=subprocess.STDOUT, 125 | ) 126 | if res.returncode != 0: 127 | _collectErrors( 128 | {"source": f"helm pull for '{fileToValidate}'", "message": f"\n{res.stdout}"} 129 | ) 130 | continue 131 | 132 | res = subprocess.run( 133 | "helm lint -f values.yaml *.tgz", 134 | shell=True, 135 | cwd=tmpDir, 136 | text=True, 137 | stdout=subprocess.PIPE, 138 | stderr=subprocess.STDOUT, 139 | ) 140 | if res.returncode != 0: 141 | _collectErrors( 142 | {"source": f"helm lint for '{fileToValidate}'", "message": f"\n{res.stdout}"} 143 | ) 144 | 145 | 146 | def _collectErrors(error): 147 | errors.append(error) 148 | 149 | 150 | def _printErrors(): 151 | for i in errors: 152 | print(f"[ERROR] {i['source']}: {i['message']}") 153 | 154 | 155 | if __name__ == "__main__": 156 | main() 157 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyyaml 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pre_commit_flux 3 | 4 | [options] 5 | packages = find: 6 | python_requires = >= 3.8.5 7 | install_requires = 8 | pyyaml 9 | 10 | [options.entry_points] 11 | console_scripts = 12 | check-flux-helm-values = pre_commit_flux.check_flux_helm_values:main 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /tests/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - release.yaml 5 | - repository.yaml 6 | -------------------------------------------------------------------------------- /tests/default/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: helm.toolkit.fluxcd.io/v2 3 | kind: HelmRelease 4 | metadata: 5 | name: test-nginx 6 | spec: 7 | chart: 8 | spec: 9 | chart: nginx 10 | version: 19.0.2 11 | reconcileStrategy: ChartVersion 12 | sourceRef: 13 | kind: HelmRepository 14 | name: externalhelm 15 | interval: 60m 16 | -------------------------------------------------------------------------------- /tests/default/repository.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: source.toolkit.fluxcd.io/v1beta2 3 | kind: HelmRepository 4 | metadata: 5 | name: externalhelm 6 | spec: 7 | type: "oci" 8 | interval: 24h 9 | url: oci://registry-1.docker.io/bitnamicharts/ 10 | -------------------------------------------------------------------------------- /tests/invalid_kustomization/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - ../default 3 | patches: 4 | - path: release.yaml 5 | target: 6 | kind: HelmRelease 7 | name: test-nginx 8 | -------------------------------------------------------------------------------- /tests/invalid_kustomization/release.yaml: -------------------------------------------------------------------------------- 1 | kind: HelmRelease 2 | metadata: 3 | name: test-nginx 4 | spec: 5 | values: 6 | ingress: 7 | enabled: 8 8 | -------------------------------------------------------------------------------- /tests/kustomization/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - ../default 3 | patches: 4 | - path: release.yaml 5 | target: 6 | kind: HelmRelease 7 | name: test-nginx 8 | -------------------------------------------------------------------------------- /tests/kustomization/release.yaml: -------------------------------------------------------------------------------- 1 | kind: HelmRelease 2 | metadata: 3 | name: test-nginx 4 | spec: 5 | values: 6 | ingress: 7 | enabled: true 8 | -------------------------------------------------------------------------------- /tests/test_precommit.py: -------------------------------------------------------------------------------- 1 | import pre_commit_flux.check_flux_helm_values as testm 2 | import mock 3 | import pytest 4 | 5 | 6 | def test_basic_usecase(capsys): 7 | testargs = ['', 'tests/default/release.yaml'] 8 | with mock.patch('sys.argv', testargs): 9 | testm.main() 10 | out, err = capsys.readouterr() 11 | assert err == '' 12 | 13 | def test_kustomization_detect(capsys): 14 | testargs = ['', 'tests/kustomization/release.yaml'] 15 | with mock.patch('sys.argv', testargs): 16 | testm.main() 17 | out, err = capsys.readouterr() 18 | assert 'kustomization' in out 19 | 20 | def test_invalid_kustomizatoion(): 21 | testargs = ['', 'tests/invalid_kustomization/release.yaml'] 22 | with mock.patch('sys.argv', testargs): 23 | with pytest.raises(SystemExit) as e: 24 | testm.main() 25 | assert e.value.code == 1 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | --------------------------------------------------------------------------------