From 2097831d98d24f2fb1fb3c2c134b134b42ac46c1 Mon Sep 17 00:00:00 2001 From: Jiri Tyr Date: Wed, 7 Dec 2022 09:44:58 +0000 Subject: [PATCH] Adding Helm plugin and pre-commit --- .gitignore | 1 + .pre-commit-hooks.yaml | 7 + Readme.md | 239 +++++++++++++ plugin.yaml | 14 + scripts/helm/plugin_binloader.sh | 216 ++++++++++++ scripts/helm/plugin_wrapper.py | 552 +++++++++++++++++++++++++++++++ scripts/helm/pre-commit.py | 117 +++++++ 7 files changed, 1146 insertions(+) create mode 100644 .pre-commit-hooks.yaml create mode 100644 plugin.yaml create mode 100755 scripts/helm/plugin_binloader.sh create mode 100755 scripts/helm/plugin_wrapper.py create mode 100755 scripts/helm/pre-commit.py diff --git a/.gitignore b/.gitignore index 7a52c72..fa489e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ bin/ .idea/ +**/*.pyc diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..613275d --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,7 @@ +- id: kubeconform-helm + name: Kubeconform Helm + description: Run kubeconform for Helm charts + language: script + entry: scripts/helm/pre-commit.py + files: ^charts/[^/]+/(\.kubeconform|\.helmignore|templates/NOTES.txt|.*\.(ya?ml|json|tpl))$ + require_serial: true diff --git a/Readme.md b/Readme.md index b1b905c..9d414f5 100644 --- a/Readme.md +++ b/Readme.md @@ -49,6 +49,9 @@ sys 0m1,069s * [Integrating Kubeconform in the CI](#Integrating-Kubeconform-in-the-CI) * [Github Workflow](#Github-Workflow) * [Gitlab-CI](#Gitlab-CI) +* [Helm charts](#helm-charts) + * [Helm plugin](#helm-plugin) + * [Helm `pre-commit` hook](#helm-pre-commit-hook) * [Using kubeconform as a Go Module](#Using-kubeconform-as-a-Go-Module) * [Credits](#Credits) @@ -325,6 +328,242 @@ lint-kubeconform: See [issue 106](https://github.com/yannh/kubeconform/issues/106) for more details. +## Helm charts + +`kubeconform` supports automation for [Helm charts](https://helm.sh) in the form +of [Helm plugin](https://helm.sh/docs/topics/plugins/) and [`pre-commit` +hook](https://pre-commit.com/). + +### Helm plugin + +The `kubeconform` [Helm plugin](https://helm.sh/docs/topics/plugins/) can be +installed using this command: + +```shell +helm plugin install https://github.com/yannh/kubeconform +``` + +Once installed, the plugin can be used from any Helm chart directory: + +```shell +# Enter the chart directory +cd charts/mychart +# Run kubeconform plugin +helm kubeconform . +``` + +The plugin uses `helm template` internally and passes its output to the +`kubeconform`. There is several `helm template` command line options supported +by the plugin that can be specified: + +```shell +helm kubeconform --namespace myns . +``` + +There is also several `kubeconform` command line options supported by the plugin +that can be specified: + +```shell +# Kubeconform options +helm kubeconform --verbose --summary . +``` + +It's also possible to create `.kubeconform` file in the Helm chart directory +that can contain default `kubeconform` settings: + +```yaml +# Command line options that can be set multiple times can be defined as an array +schema-location: + - default + - https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json +# Command line options that can be specified without a value must have boolean +# value in the config file +summary: true +verbose: true +``` + +The full list of options for the +[plugin](https://github.com/yannh/kubeconform/blob/master/scripts/helm/plugin_wrapper.py) +is as follows: + +```text +$ ./plugin_wrapper.py --help +usage: plugin_wrapper.py [-h] [--cache] [--cache-dir DIR] [--config FILE] [--values-dir DIR] + [--values-pattern PATTERN] [--debug] [--skip-refresh] [--verify] + [-f FILE] [-n NAME] [-r NAME] [--ignore-missing-schemas] + [--insecure-skip-tls-verify] [--kubernetes-version VERSION] + [--goroutines NUMBER] [--output {json,junit,tap,text}] + [--reject LIST] [--schema-location LOCATION] [--skip LIST] + [--strict] [--summary] [--verbose] + CHART + +Wrapper to run kubeconform for a Helm chart. + +options: + -h, --help show this help message and exit + --cache whether to use kubeconform cache + --cache-dir DIR path to the cache directory (default: ~/.cache/kubeconform) + --config FILE config file name (default: .kubeconform) + --values-dir DIR directory with optional values files for the tests (default: ci) + --values-pattern PATTERN + pattern to select the values files (default: *-values.yaml) + --debug debug output + +helm build: + Options passed to the 'helm build' command + + --skip-refresh do not refresh the local repository cache + --verify verify the packages against signatures + +helm template: + Options passed to the 'helm template' command + + -f FILE, --values FILE + values YAML file or URL (can specified multiple) + -n NAME, --namespace NAME + namespace + -r NAME, --release NAME + release name + CHART chart path (e.g. '.') + +kubeconform: + Options passsed to the 'kubeconform' command + + --ignore-missing-schemas + skip files with missing schemas instead of failing + --insecure-skip-tls-verify + disable verification of the server's SSL certificate + --kubernetes-version VERSION + version of Kubernetes to validate against, e.g. 1.18.0 (default: + master) + --goroutines NUMBER number of goroutines to run concurrently (default: 4) + --output {json,junit,tap,text} + output format (default: text) + --reject LIST comma-separated list of kinds or GVKs to reject + --schema-location LOCATION + override schemas location search path (can specified multiple) + --skip LIST comma-separated list of kinds or GVKs to ignore + --strict disallow additional properties not in schema or duplicated keys + --summary print a summary at the end (ignored for junit output) + --verbose print results for all resources (ignored for tap and junit output) +``` + +## Helm `pre-commit` hook + +The `kubeconform` [`pre-commit` hook](https://pre-commit.com) can be added into the +`.pre-commit-config.yaml` file like this: + +```yaml +repos: + - repo: https://github.com/yannh/kubeconform + rev: v0.5.0 + hooks: + - id: kubeconform-helm +``` + +The hook uses `helm template` internally and passes its output to the +`kubeconform`. There is several `helm template` command line options supported +by the hook that can be specified: + +```yaml + - repo: https://github.com/yannh/kubeconform + rev: v0.5.0 + hooks: + - id: kubeconform-helm + args: + - --namespace=myns + - --release=myrelease +``` + +There is also several `kubeconform` command line options supported by the hook +that can be specified: + +```yaml + - repo: https://github.com/yannh/kubeconform + rev: v0.5.0 + hooks: + - id: kubeconform-helm + args: + - --kubernetes-version=1.24.0 + - --verbose + - --summary +``` + +The full list of options for the +[hook](https://github.com/yannh/kubeconform/blob/master/scripts/helm/pre-commit.py) +is as follows: + +```text +$ ./pre-commit.py --help +usage: pre-commit.py [-h] [--charts-path PATH] [--include-charts LIST] + [--exclude-charts LIST] [--cache] [--cache-dir DIR] [--config FILE] + [--values-dir DIR] [--values-pattern PATTERN] [--debug] + [--skip-refresh] [--verify] [-f FILE] [-n NAME] [-r NAME] + [--ignore-missing-schemas] [--insecure-skip-tls-verify] + [--kubernetes-version VERSION] [--goroutines NUMBER] + [--output {json,junit,tap,text}] [--reject LIST] + [--schema-location LOCATION] [--skip LIST] [--strict] [--summary] + [--verbose] + FILES [FILES ...] + +Wrapper to run kubeconform for a Helm chart. + +positional arguments: + FILES files that have changed + +options: + -h, --help show this help message and exit + --charts-path PATH path to the directory with charts (default: charts) + --include-charts LIST + comma-separated list of chart names to include in the testing + --exclude-charts LIST + comma-separated list of chart names to exclude from the testing + --cache whether to use kubeconform cache + --cache-dir DIR path to the cache directory (default: ~/.cache/kubeconform) + --config FILE config file name (default: .kubeconform) + --values-dir DIR directory with optional values files for the tests (default: ci) + --values-pattern PATTERN + pattern to select the values files (default: *-values.yaml) + --debug debug output + +helm build: + Options passed to the 'helm build' command + + --skip-refresh do not refresh the local repository cache + --verify verify the packages against signatures + +helm template: + Options passed to the 'helm template' command + + -f FILE, --values FILE + values YAML file or URL (can specified multiple) + -n NAME, --namespace NAME + namespace + -r NAME, --release NAME + release name + +kubeconform: + Options passsed to the 'kubeconform' command + + --ignore-missing-schemas + skip files with missing schemas instead of failing + --insecure-skip-tls-verify + disable verification of the server's SSL certificate + --kubernetes-version VERSION + version of Kubernetes to validate against, e.g. 1.18.0 (default: + master) + --goroutines NUMBER number of goroutines to run concurrently (default: 4) + --output {json,junit,tap,text} + output format (default: text) + --reject LIST comma-separated list of kinds or GVKs to reject + --schema-location LOCATION + override schemas location search path (can specified multiple) + --skip LIST comma-separated list of kinds or GVKs to ignore + --strict disallow additional properties not in schema or duplicated keys + --summary print a summary at the end (ignored for junit output) + --verbose print results for all resources (ignored for tap and junit output) +``` + ## Using kubeconform as a Go Module **Warning**: This is a work-in-progress, the interface is not yet considered stable. Feedback is encouraged. diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..1659bb1 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,14 @@ +name: kubeconform +version: 0.5.0 +usage: Kubernetes manifest validation tool for Helm charts +description: Kubernetes manifest validation tool for Helm charts +ignoreFlags: false +command: >- + $HELM_PLUGIN_DIR/scripts/helm/plugin_wrapper.py +hooks: + install: >- + cd $HELM_PLUGIN_DIR; + ./scripts/helm/plugin_binloader.sh + update: >- + cd $HELM_PLUGIN_DIR; + HELM_PLUGIN_UPDATE=1 ./scripts/helm/plugin_binloader.sh diff --git a/scripts/helm/plugin_binloader.sh b/scripts/helm/plugin_binloader.sh new file mode 100755 index 0000000..a540b08 --- /dev/null +++ b/scripts/helm/plugin_binloader.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env sh + +# This script was borrowed from https://github.com/quintush/helm-unittest + +if [ -z "$HELM_PLUGIN_DIR" ]; then + echo "No HELM_PLUGIN_DIR defined" + + exit 1 +fi + +PROJECT_NAME='kubeconform' +PROJECT_GH="yannh/$PROJECT_NAME" +PROJECT_CHECKSUM_FILE='CHECKSUMS' +HELM_PLUGIN_PATH="$HELM_PLUGIN_DIR" + +# Convert the HELM_PLUGIN_PATH to unix if cygpath is +# available. This is the case when using MSYS2 or Cygwin +# on Windows where helm returns a Windows path but we +# need a Unix path +if type cygpath >/dev/null 2>&1; then + echo 'Use Sygpath' + + HELM_PLUGIN_PATH=$(cygpath -u "$HELM_PLUGIN_PATH") +fi + +if [ "$SKIP_BIN_INSTALL" = '1' ]; then + echo 'Skipping binary install' + + exit +fi + +# fail_trap is executed if an error occurs. +fail_trap() { + result=$? + + if [ "$result" != '0' ]; then + echo "Failed to install $PROJECT_NAME" + echo 'For support, go to https://github.com/kubernetes/helm' + fi + + exit $result +} + +# initArch discovers the architecture for this system. +initArch() { + ARCH=$(uname -m) + + case "$ARCH" in + armv5*) ARCH='armv5';; + armv6*) ARCH='armv6';; + armv7*) ARCH='armv7';; + aarch64) ARCH='arm64';; + x86) ARCH='386';; + x86_64) ARCH='amd64';; + i686) ARCH='386';; + i386) ARCH='386';; + esac +} + +# initOS discovers the operating system for this system. +initOS() { + OS=$(uname | tr '[:upper:]' '[:lower:]') + + case "$OS" in + # Msys support + msys*) OS='windows';; + # Minimalist GNU for Windows + mingw*) OS='windows';; + # MacOS + darwin) OS='darwin';; + esac +} + +# verifySupported checks that the os/arch combination is supported for +# binary builds. +verifySupported() { + supported='darwin-arm64,darwin-amd64,linux-amd64,linux-armv6,linux-arm64,linux-386,windows-arm64,windows-armv6,windows-amd64,windows-386' + + if ! echo "$supported" | grep -q "$OS-$ARCH"; then + echo "No prebuild binary for $OS-$ARCH" + + exit 1 + fi + + if type curl >/dev/null 2>&1; then + DOWNLOADER='curl' + elif type wget >/dev/null 2>&1; then + DOWNLOADER='wget' + else + echo 'Either curl or wget is required' + + exit 1 + fi + + echo "Support $OS-$ARCH" +} + +# getDownloadURL checks the latest available version. +getDownloadURLs() { + # Use the GitHub API to find the latest version for this project. + latest_url="https://api.github.com/repos/$PROJECT_GH/releases/latest" + + if [ -z "$HELM_PLUGIN_UPDATE" ]; then + version=$(git describe --tags --exact-match 2>/dev/null || true) + + if [ -n "$version" ]; then + latest_url="https://api.github.com/repos/$PROJECT_GH/releases/tags/$version" + fi + fi + + echo "Retrieving $latest_url" + + if [ $DOWNLOADER = 'curl' ]; then + DOWNLOAD_URL=$(curl -sL "$latest_url" | grep "$OS-$ARCH" | awk '/\"browser_download_url\":/{gsub(/[,\"]/,"", $2); print $2}' 2>/dev/null) + PROJECT_CHECKSUM=$(curl -sL "$latest_url" | grep "$PROJECT_CHECKSUM_FILE" | awk '/\"browser_download_url\":/{gsub(/[,\"]/,"", $2); print $2}' 2>/dev/null) + elif [ $DOWNLOADER = 'wget' ]; then + DOWNLOAD_URL=$(wget -q -O - "$latest_url" | grep "$OS-$ARCH" | awk '/\"browser_download_url\":/{gsub(/[,\"]/,"", $2); print $2}' 2>/dev/null) + PROJECT_CHECKSUM=$(wget -q -O - "$latest_url" | grep "$PROJECT_CHECKSUM_FILE" | awk '/\"browser_download_url\":/{gsub(/[,\"]/,"", $2); print $2}' 2>/dev/null) + fi + + if [ -z "$DOWNLOAD_URL" ]; then + echo 'Failed to get DOWNLOAD_URL' + + exit 1 + elif [ -z "$PROJECT_CHECKSUM" ]; then + echo 'Failed to get PROJECT_CHECKSUM' + + exit 1 + fi +} + +# downloadFiles downloads the latest binary package and also the checksum +# for that binary. +downloadFiles() { + PLUGIN_TMP_FOLDER=$(mktemp -d) + CHECKSUM_FILE_PATH="$PLUGIN_TMP_FOLDER/$PROJECT_CHECKSUM_FILE" + + echo "Downloading '$DOWNLOAD_URL' and '$PROJECT_CHECKSUM' to location $PLUGIN_TMP_FOLDER" + + if [ $DOWNLOADER = 'curl' ]; then + (cd "$PLUGIN_TMP_FOLDER" && curl -sLO "$DOWNLOAD_URL") + curl -s -L -o "$CHECKSUM_FILE_PATH" "$PROJECT_CHECKSUM" + elif [ $DOWNLOADER = 'wget' ]; then + wget -P "$PLUGIN_TMP_FOLDER" "$DOWNLOAD_URL" + wget -q -O "$CHECKSUM_FILE_PATH" "$PROJECT_CHECKSUM" + fi +} + +# installFile verifies the SHA256 for the file, then unpacks and +# installs it. +installFile() { + echo 'Verifying SHA for the file' + + DOWNLOAD_FILE=$(find "$PLUGIN_TMP_FOLDER" -name "*.tar.gz") + + if [ -z "$DOWNLOAD_FILE" ]; then + DOWNLOAD_FILE=$(find "$PLUGIN_TMP_FOLDER" -name "*.zip") + fi + + DOWNLOAD_FILE_NAME=$(basename "$DOWNLOAD_FILE") + + ( + cd "$PLUGIN_TMP_FOLDER" + + if type shasum >/dev/null 2>&1; then + grep "$DOWNLOAD_FILE_NAME" "$CHECKSUM_FILE_PATH" | shasum -a 256 -c -s + elif type sha256sum >/dev/null 2>&1; then + if grep -q 'ID=alpine' /etc/os-release; then + grep "$DOWNLOAD_FILE_NAME" "$CHECKSUM_FILE_PATH" | sha256sum -c -s + else + grep "$DOWNLOAD_FILE_NAME" "$CHECKSUM_FILE_PATH" | sha256sum -c --status + fi + else + echo 'No Checksum as there is no shasum or sha256sum found' + fi + ) + + HELM_TMP="$PLUGIN_TMP_FOLDER/$PROJECT_NAME" + mkdir "$HELM_TMP" + + tar -C "$HELM_TMP" -xf "$DOWNLOAD_FILE" + + echo "Preparing to install into $HELM_PLUGIN_PATH" + + HELM_TMP_BIN="$HELM_TMP/$PROJECT_NAME" + + # Use * to also copy the file with the exe suffix on Windows + cp "$HELM_TMP_BIN"* "$HELM_PLUGIN_PATH/bin/" + + rm -r "$HELM_TMP" + rm -r "$PLUGIN_TMP_FOLDER" + + echo "$PROJECT_NAME installed into $HELM_PLUGIN_PATH/bin" +} + +# testVersion tests the installed client to make sure it is working. +testVersion() { + # To avoid to keep track of the Windows suffix, + # call the plugin assuming it is in the PATH + PATH="$HELM_PLUGIN_PATH/bin:$PATH" + + kubeconform -v +} + +# Stop execution on any error +trap "fail_trap" EXIT +set -e + +# Execution +initArch +initOS +verifySupported +getDownloadURLs +downloadFiles +installFile +testVersion diff --git a/scripts/helm/plugin_wrapper.py b/scripts/helm/plugin_wrapper.py new file mode 100755 index 0000000..5263dce --- /dev/null +++ b/scripts/helm/plugin_wrapper.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 + +import argparse +import logging +import os +import subprocess +import sys +import yaml + +from glob import glob + + +def parse_args(add_chart=True, add_files=False, add_path=False, add_incl_excl=False): + # Command line options for helm, kubeconform and the plugin itself + args = { + "helm_tmpl": [], + "helm_build": [], + "kubeconform": [], + "wrapper": [], + } + + # Define parser + parser = argparse.ArgumentParser( + description="Wrapper to run kubeconform for a Helm chart." + ) + + if add_path: + parser.add_argument( + "--charts-path", + metavar="PATH", + help="path to the directory with charts (default: charts)", + default="charts", + ) + + if add_incl_excl: + parser.add_argument( + "--include-charts", + metavar="LIST", + help="comma-separated list of chart names to include in the testing", + ) + parser.add_argument( + "--exclude-charts", + metavar="LIST", + help="comma-separated list of chart names to exclude from the testing", + ) + + parser.add_argument( + "--cache", help="whether to use kubeconform cache", action="store_true" + ) + parser.add_argument( + "--cache-dir", + metavar="DIR", + help="path to the cache directory (default: ~/.cache/kubeconform)", + default="~/.cache/kubeconform", + ) + parser.add_argument( + "--config", + metavar="FILE", + help="config file name (default: .kubeconform)", + default=".kubeconform", + ) + parser.add_argument( + "--values-dir", + metavar="DIR", + help="directory with optional values files for the tests (default: ci)", + default="ci", + ) + parser.add_argument( + "--values-pattern", + metavar="PATTERN", + help="pattern to select the values files (default: *-values.yaml)", + default="*-values.yaml", + ) + + parser.add_argument( + "--debug", + help="debug output", + action="store_true", + ) + + group_helm_build = parser.add_argument_group( + "helm build", "Options passed to the 'helm build' command" + ) + + group_helm_build.add_argument( + "--skip-refresh", + help="do not refresh the local repository cache", + action="store_true", + ) + group_helm_build.add_argument( + "--verify", help="verify the packages against signatures", action="store_true" + ) + + group_helm_tmpl = parser.add_argument_group( + "helm template", "Options passed to the 'helm template' command" + ) + + group_helm_tmpl.add_argument( + "-f", + "--values", + metavar="FILE", + help="values YAML file or URL (can specified multiple)", + action="append", + ) + group_helm_tmpl.add_argument( + "-n", + "--namespace", + metavar="NAME", + help="namespace", + ) + group_helm_tmpl.add_argument( + "-r", + "--release", + metavar="NAME", + help="release name", + ) + + if add_chart: + group_helm_tmpl.add_argument( + "CHART", + help="chart path (e.g. '.')", + ) + + group_kubeconform = parser.add_argument_group( + "kubeconform", "Options passsed to the 'kubeconform' command" + ) + + group_kubeconform.add_argument( + "--ignore-missing-schemas", + help="skip files with missing schemas instead of failing", + action="store_true", + ) + group_kubeconform.add_argument( + "--insecure-skip-tls-verify", + help="disable verification of the server's SSL certificate", + action="store_true", + ) + group_kubeconform.add_argument( + "--kubernetes-version", + metavar="VERSION", + help="version of Kubernetes to validate against, e.g. 1.18.0 (default: master)", + ) + group_kubeconform.add_argument( + "--goroutines", + metavar="NUMBER", + help="number of goroutines to run concurrently (default: 4)", + ) + group_kubeconform.add_argument( + "--output", + help="output format (default: text)", + choices=["json", "junit", "tap", "text"], + ) + group_kubeconform.add_argument( + "--reject", + metavar="LIST", + help="comma-separated list of kinds or GVKs to reject", + ) + group_kubeconform.add_argument( + "--schema-location", + metavar="LOCATION", + help="override schemas location search path (can specified multiple)", + action="append", + ) + group_kubeconform.add_argument( + "--skip", + metavar="LIST", + help="comma-separated list of kinds or GVKs to ignore", + ) + group_kubeconform.add_argument( + "--strict", + help="disallow additional properties not in schema or duplicated keys", + action="store_true", + ) + group_kubeconform.add_argument( + "--summary", + help="print a summary at the end (ignored for junit output)", + action="store_true", + ) + group_kubeconform.add_argument( + "--verbose", + help="print results for all resources (ignored for tap and junit output)", + action="store_true", + ) + + if add_files: + parser.add_argument( + "FILES", + help="files that have changed", + nargs="+", + ) + + # Parse the args + a = parser.parse_args() + + # ### Populate the helm build options + if a.skip_refresh: + args["helm_build"] = ["--skip-refresh"] + + if a.verify: + args["helm_build"] = ["--verify"] + + # This must stay the last item from 'helm_build'! + if add_chart: + args["helm_build"] += [a.CHART] + + # ### Populate the helm template options + if a.values: + for v in a.values: + args["helm_tmpl"] += ["--values", v] + + if a.namespace is not None: + args["helm_tmpl"] += ["--namespace", a.namespace] + + if a.release is not None: + args["helm_tmpl"] += [a.release] + + # This must stay the last item from 'helm_tmpl'! + if add_chart: + args["helm_tmpl"] += [a.CHART] + + # ### Polulate the kubeconform options + if a.cache: + args["kubeconform"] += ["-cache", os.path.expanduser(a.cache_dir)] + + if a.ignore_missing_schemas is True: + args["kubeconform"] += ["-ignore-missing-schemas"] + + if a.insecure_skip_tls_verify is True: + args["kubeconform"] += ["-insecure-skip-tls-verify"] + + if a.kubernetes_version is not None: + args["kubeconform"] += ["-kubernetes-version", a.kubernetes_version] + + if a.goroutines is not None: + args["kubeconform"] += ["-n", a.goroutines] + + if a.output is not None: + args["kubeconform"] += ["-output", a.output] + + if a.reject is True: + args["kubeconform"] += ["-reject"] + + if a.schema_location: + for v in a.schema_location: + args["kubeconform"] += ["-schema-location", v] + + if a.skip is True: + args["kubeconform"] += ["-skip"] + + if a.strict is True: + args["kubeconform"] += ["-strict"] + + if a.summary is True: + args["kubeconform"] += ["-summary"] + + if a.verbose is True: + args["kubeconform"] += ["-verbose"] + + # ### All args are wrapper options + args["wrapper"] = a + + return args + + +def get_logger(debug): + if debug: + level = logging.DEBUG + else: + level = logging.ERROR + + format = "%(levelname)s: %(message)s" + + logging.basicConfig(level=level, format=format) + + return logging.getLogger(__name__) + + +def parse_config(filename): + args = [] + + # Check if file exists + if not os.path.isfile(filename): + return args + + # Read and parse the file + try: + with open(filename, "r") as stream: + try: + data = yaml.load(stream, Loader=yaml.Loader) + except yaml.YAMLError as e: + raise Exception("cannot parse YAML file '%s': %s" % (filename, e)) + except IOError as e: + raise Exception("cannot open file '%s': %s" % (filename, e)) + + # Produce extra args out of the config file + if isinstance(data, dict): + for key, val in data.items(): + if isinstance(val, list): + for v in val: + args.append("-%s=%s" % (key, v)) + elif isinstance(v, dict): + # No deep dicts allowed in the config + continue + else: + args.append("-%s=%s" % (key, val)) + + return args + + +def get_values_files(values_dir, values_pattern): + values_files = [] + + # Get values files + if os.path.isdir(values_dir): + values_files = glob(os.path.join(values_dir, values_pattern)) + + return values_files + + +def run_helm_dependecy_build(args): + # Check if it's local chart + if not os.path.isfile(os.path.join(args[-1], "Chart.yaml")): + return + + charts_dir = os.path.join(args[-1], "charts") + + # Check if the dependency charts are already there + if os.path.isdir(charts_dir): + with open(os.path.join(args[-1], "Chart.yaml"), "r") as f: + try: + data = yaml.safe_load(f) + except yaml.YAMLError as e: + raise Exception("failed to parse Chart.yaml: %s" % e) + + if "dependencies" in data: + for d in data["dependencies"]: + if not ( + "name" in d + and "version" in d + and os.path.isfile( + os.path.join( + charts_dir, "%s-%s.tgz" % (d["name"], d["version"]) + ) + ) + ): + # Dependency missing, let's get it + break + else: + # All dependencies seem to be there so don't run anything + return + + # Check if there is Chart.lock + if os.path.isfile(os.path.join(args[-1], "Chart.yaml")): + action = "update" + else: + action = "build" + + # Run process + result = subprocess.run( + [ + os.getenv("HELM_BIN", "helm"), + "dependency", + action, + ] + + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Check for errors + if result.returncode != 0: + raise Exception( + "failed to run helm dependency build: rc=%d %s" + % (result.returncode, result.stderr) + ) + + +def run_helm_template(args): + # Run process + result = subprocess.run( + [ + os.getenv("HELM_BIN", "helm"), + "template", + ] + + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Check for errors + if result.returncode != 0: + raise Exception( + "failed to run helm template: rc=%d %s" % (result.returncode, result.stderr) + ) + + return result + + +def run_kubeconform(args, input): + bin_file = "kubeconform" + + # Try to use `HELM_PLUGIN_DIR` env var to determine location of kubeconform + helm_plugin_dir = os.getenv("HELM_PLUGIN_DIR", "") + helm_plugin_bin = os.path.join(helm_plugin_dir, "bin", bin_file) + + if os.path.isfile(helm_plugin_bin): + bin_file = helm_plugin_bin + else: + helm_error = False + + # Try to use `helm env` to determine location of kubeconform + try: + result = subprocess.run( + [ + "helm", + "env", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except Exception: + helm_error = True + + if not helm_error: + for line in result.stdout.split("\n"): + if line.startswith("HELM_PLUGINS") and "=" in line: + _, plugins_path = line.split("=") + + helm_plugin_bin = os.path.join( + plugins_path.strip('"'), bin_file, "bin", bin_file + ) + + if os.path.isfile(helm_plugin_bin): + bin_file = helm_plugin_bin + + # Create the cache dir + if "-cache" in args: + c_idx = args.index("-cache") + c_dir = args[c_idx + 1] + + if not os.path.isdir(c_dir): + try: + os.mkdir(c_dir, 0o755) + except OSError as e: + raise Exception("failed to create cache directory: %s" % e) + + # Run process + result = subprocess.run( + [ + bin_file, + ] + + args, + input=input, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Check for errors + if result.returncode != 0: + raise Exception( + "failed to run kubeconform: rc=%d\n%s%s" + % (result.returncode, result.stderr, result.stdout) + ) + + return result + + +def run_test(args, values_file=None): + values_args = [] + + # Add extra values parameter if any file is specified + if values_file: + values_args = [ + "--values", + values_file, + ] + + # Build Helm dependencies + try: + run_helm_dependecy_build( + args["helm_build"], + ) + except Exception as e: + raise Exception("dependency build failed: %s" % e) + + # Get templated output + try: + helm_result = run_helm_template( + args["helm_tmpl"] + values_args, + ) + except Exception as e: + raise Exception("templating failed: %s" % e) + + # Get kubeconform output + try: + kubeconform_result = run_kubeconform( + args["kubeconform"], + helm_result.stdout, + ) + except Exception as e: + raise Exception("kubeconform failed: %s" % e) + + # Print results + if kubeconform_result.stdout: + print(kubeconform_result.stdout.rstrip()) + + +def main(): + # Parse args + args = parse_args() + + # Ger logger + log = get_logger(args["wrapper"].debug) + + # Parse config file + config_args = parse_config( + args["wrapper"].config, + ) + + # Merge the args from config file and from command line + if config_args: + args["kubeconform"] = config_args + args["kubeconform"] + + # Get list of values files + values_files = get_values_files( + args["wrapper"].values_dir, + args["wrapper"].values_pattern, + ) + + # Run tests + try: + if values_files: + for values_file in values_files: + log.debug("Testing with CI values file %s" % values_file) + + run_test(args, values_file) + else: + log.debug("Testing without CI values files") + + run_test(args) + except Exception as e: + log.error("Testing failed: %s" % e) + + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/helm/pre-commit.py b/scripts/helm/pre-commit.py new file mode 100755 index 0000000..664e9e1 --- /dev/null +++ b/scripts/helm/pre-commit.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 + +import os +import sys + +from contextlib import contextmanager + +import plugin_wrapper as pw + + +@contextmanager +def cd(newdir): + prevdir = os.getcwd() + + os.chdir(os.path.expanduser(newdir)) + + try: + yield + finally: + os.chdir(prevdir) + + +def main(): + # Parse args + args = pw.parse_args( + add_chart=False, + add_files=True, + add_path=True, + add_incl_excl=True, + ) + + # We gonna change directory into the chart directory so we add it as local + # path for helm dependency build and helm template + args["helm_build"].append(".") + args["helm_tmpl"].append(".") + + # Ger logger + log = pw.get_logger( + args["wrapper"].debug, + ) + + # Here we store paths fo the changed charts + charts = {} + + # Calculate length of the path to the directory with charts + path_items = args["wrapper"].charts_path.split(os.sep) + + # Take only paths pointing to files in the chart + path_items_len = len(path_items) + 1 + + # Includes and excludes + if args["wrapper"].include_charts is not None: + include_charts = list(map(str.strip, args["wrapper"].include_charts.split(","))) + else: + include_charts = [] + + if args["wrapper"].exclude_charts is not None: + exclude_charts = list(map(str.strip, args["wrapper"].exclude_charts.split(","))) + else: + exclude_charts = [] + + for f in args["wrapper"].FILES: + if f.startswith("%s%s" % (args["wrapper"].charts_path, os.sep)): + items = f.split(os.sep) + name = items[path_items_len - 1] + + # Skip chart if it's not included or is excluded + if ( + include_charts and name not in include_charts + ) or name in exclude_charts: + continue + + if len(items) > path_items_len: + path = os.sep.join(items[0:path_items_len]) + + if path not in charts: + charts[name] = path + + # Change directory to the chart and run tests + for name, path in charts.items(): + print("Testing chart '%s'" % name) + + with cd(path): + # Parse config file + config_args = pw.parse_config( + args["wrapper"].config, + ) + + # Merge the args from config file and from command line + if config_args: + args["kubeconform"] = config_args + args["kubeconform"] + + # Get list of values files + values_files = pw.get_values_files( + args["wrapper"].values_dir, + args["wrapper"].values_pattern, + ) + + # Run tests + try: + if values_files: + for values_file in values_files: + log.debug("Testing with an extra values file %s" % values_file) + + pw.run_test(args, values_file) + else: + log.debug("Testing without any extra values files") + + pw.run_test(args) + except Exception as e: + log.error("Testing failed: %s" % e) + + sys.exit(1) + + +if __name__ == "__main__": + main()