diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..794c2dc --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,12 @@ +version: 2 +jobs: + build: + docker: + - image: koalaman/shellcheck-alpine + steps: + - checkout + - run: + name: lint + command: | + shellcheck -x lib/chartlib.sh + shellcheck -x chart_test.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..04fc425 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.history +.idea +.editorconfig +.gitignore +.git +Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c10b6cb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Copyright 2018 The Helm Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +#     http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM bash:4.4 + +RUN apk --no-cache add \ + curl \ + git \ + jq \ + libc6-compat \ + openssh-client \ + python \ + py-crcmod \ + py-pip + +# Install YQ command line reader +ARG YQ_VERSION=2.5.0 +RUN pip install "yq==$YQ_VERSION" + +# Install SemVer testing tool +ARG VERT_VERSION=0.1.0 +RUN curl -Lo vert "https://github.com/Masterminds/vert/releases/download/v$VERT_VERSION/vert-v$VERT_VERSION-linux-amd64" && \ + chmod +x vert && \ + mv vert /usr/local/bin/ + +# Install a YAML Linter +ARG YAML_LINT_VERSION=1.8.1 +RUN pip install "yamllint==$YAML_LINT_VERSION" + +# Install Yamale YAML schema validator +ARG YAMALE_VERSION=1.7.0 +RUN pip install "yamale==$YAMALE_VERSION" + +# Install kubectl +ARG KUBECTL_VERSION=1.10.2 +RUN curl -LO "https://storage.googleapis.com/kubernetes-release/release/v$KUBECTL_VERSION/bin/linux/amd64/kubectl" && \ + chmod +x kubectl && \ + mv kubectl /usr/local/bin/ + +# Install Helm +ARG HELM_VERSION=2.9.1 +RUN curl -LO "https://kubernetes-helm.storage.googleapis.com/helm-v$HELM_VERSION-linux-amd64.tar.gz" && \ + mkdir -p "/usr/local/helm-$HELM_VERSION" && \ + tar -xzf "helm-v$HELM_VERSION-linux-amd64.tar.gz" -C "/usr/local/helm-$HELM_VERSION" && \ + ln -s "/usr/local/helm-$HELM_VERSION/linux-amd64/helm" /usr/local/bin/helm && \ + rm -f "helm-v$HELM_VERSION-linux-amd64.tar.gz" + +COPY etc /testing/etc/ +COPY lib /testing/lib/ +COPY chart_test.sh /testing/ + +RUN ln -s /testing/chart_test.sh /usr/local/bin/chart_test.sh diff --git a/README.md b/README.md index 9fec73a..a0f5a0d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,144 @@ # Chart Testing -WIP +Bash library for linting and testing Helm charts. Comes prepackaged as Docker image for easy use. + +[chartlib.sh](lib/chartlib.sh) is a Bash library with useful function for linting and testing charts. It is well documented and should be easily usable. The script is meant to be sourced and can be configured via environment variables. + +As a convenience, [chart_test.sh](chart_test.sh) is provided. It supports linting and testing charts that have changed against a target branch. + +## Prerequisites + +It is recommended to use the provided Docker image. It comes with all necessary tools installed. + +* Bash 4.4 (https://tiswww.case.edu/php/chet/bash/bashtop.html) +* Helm (http://helm.sh) +* yq (https://github.com/kislyuk/yq) +* vert (https://github.com/Masterminds/vert) +* yamllint (https://github.com/adrienverge/yamllint) +* yamale (https://github.com/23andMe/Yamale) +* kubectl (https://kubernetes.io/docs/reference/kubectl/overview/) +* Tooling for your cluster + +Note that older Bash versions may no work! + +## Installation + +Clone the repository and add it to the `PATH`. The script must be run in the root directory of a Git repository. + +```shell +$ chart_test.sh --help +Usage: chart_test.sh + Lint, install, and test Helm charts. + -h, --help Display help + --verbose Display verbose output + --no-lint Skip chart linting + --no-install Skip chart installation + --config Path to the config file (optional) +``` + +## Configuration + +The following environment variables can be set to configure [chartlib.sh](lib/chartlib.sh). Note that this must be done before the script is sourced. + +| Variable | Description | Default | +| - | - | - | +| `REMOTE` | The name of the Git remote to check against for changed charts | `origin` | +| `TARGET_BRANCH` | The name of the Git target branch to check against for changed charts | `master` | +| `CHART_DIRS` | Array of directories relative to the repo root containing charts | `(charts)` | +| `EXCLUDED_CHARTS` | Array of directories of charts that should be skipped | `()` | +| `CHART_REPOS` | Array of additional chart repos to add (`=`) | `()` | +| `TIMEOUT` | Timeout for chart installation in seconds | `300` | +| `LINT_CONF` | Config file for YAML linter | `/testing/etc/lintconf.yaml` (path of default config file in Docker image) | +| `CHART_YAML_SCHEMA` | YAML schema for `Chart.yaml` | `/testing/etc/chart_schema.yaml` (path of default schema file in Docker image) | +| `VALIDATE_MAINTAINERS`| If `true`, maintainer names in `Chart.yaml` are validated to be existing Github accounts | `true` | + +Note that `CHART_DIRS`, `EXCLUDED_CHARTS`, and `CHART_REPOS` must be configured as Bash arrays. + +## Usage + +The library is meant to be used for linting and testing pull requests. It automatically detects charts changed against the target branch. The environment variables mentioned in the configuration section above can be set in a config file for `chart_test.sh`. + +By default, changes are detected against `origin/master`. Depending on your CI setup, it may be necessary to configure and fetch a separate remote for this. + +```shell +REMOTE=myremote +``` +```shell +git remote add myremote +git fetch myremote +chart-test.sh +``` + +### Linting Charts + +```shell +docker run --rm -v "$(pwd):/workdir" --workdir /workdir gcr.io/kubernetes-charts-ci/chart-testing:v1.0.2 chart_test.sh --no-install --config .mytestenv +``` + +*Sample Output* + +``` +----------------------------------------------------------------------- +Environment: +REMOTE=k8s +TARGET_BRANCH=master +CHART_DIRS=stable +EXCLUDED_CHARTS= +CHART_REPOS= +TIMEOUT=600 +LINT_CONF=/testing/etc/lintconf.yaml +CHART_YAML_SCHEMA=/testing/etc/chart_schema.yaml +VALIDATE_MAINTAINERS=true +----------------------------------------------------------------------- +Charts to be installed and tested: stable/dummy +Initializing Helm client... +Creating /home/testing/.helm +Creating /home/testing/.helm/repository +Creating /home/testing/.helm/repository/cache +Creating /home/testing/.helm/repository/local +Creating /home/testing/.helm/plugins +Creating /home/testing/.helm/starters +Creating /home/testing/.helm/cache/archive +Creating /home/testing/.helm/repository/repositories.yaml +Adding stable repo with URL: https://kubernetes-charts.storage.googleapis.com +Adding local repo with URL: http://127.0.0.1:8879/charts +$HELM_HOME has been configured at /home/testing/.helm. +Not installing Tiller due to 'client-only' flag having been set +Happy Helming! + +----------------------------------------------------------------------- +Processing chart 'stable/dummy'... +----------------------------------------------------------------------- + +Validating chart 'stable/dummy'... +Checking chart 'stable/dummy' for a version bump... +Unable to find chart on master. New chart detected. +Linting 'stable/dummy/Chart.yaml'... +Linting 'stable/dummy/values.yaml'... +Validating Chart.yaml +Validating /workdir/stable/dummy/Chart.yaml... +Validation success! 👍 +Validating maintainers +Verifying maintainer 'unguiculus'... +Using custom values file 'stable/dummy/ci/ci-values.yaml'... +Linting chart 'stable/dummy'... +==> Linting stable/dummy +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, no failures +Done. +``` + +### Installing and Testing Charts + +Installing a chart requires access to a Kubernetes cluster. You may have to create your own Docker image that extends from `gcr.io/kubernetes-charts-ci/chart-testing:v1.0.2` in order to install additional tools (e. g. `google-cloud-sdk` for GKE). You could run a container in the background, run the required steps to authenticatre and initialize the `kubectl` context before you, and eventually run `chart_test.sh`. + +Charts are installed into newly created namespaces that will be deleted again afterwards. By default, they are named by the chart, which may not be a good idea, especially when multiple PR jobs could be running for the same chart. `chart_lib.sh` looks for an environment variable `BUILD_ID` and uses it to name the namespace. Make sure you set it based on the pull request number. + +```shell +docker run --rm -v "$(pwd):/workdir" --workdir /workdir gcr.io/kubernetes-charts-ci/chart-testing:v1.0.2 chart_test.sh --no-lint --config .mytestenv +``` + +#### GKE Example + +An example for GKE is available in the [examples/gke](examples/gke) directory. A custom `Dockerfile` additionally installs the `google-cloud-sdk` and a custom shell script puts everything together. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..33d35a7 --- /dev/null +++ b/build.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +# Copyright 2018 The Helm Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +#     http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit +set -o nounset +set -o pipefail + +readonly IMAGE_TAG=v1.0.2 +readonly IMAGE_REPOSITORY="gcr.io/kubernetes-charts-ci/chart-testing" +readonly SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + +show_help() { +cat << EOF +Usage: ${0##*/} + -h, --help Display help + -v, --verbose Display verbose output + -p, --push Push image to registry +EOF +} + +main() { + local verbose= + local push= + + while :; do + case "${1:-}" in + -h|--help) + show_help + exit + ;; + -v|--verbose) + verbose=true + ;; + -p|--push) + push=true + ;; + -?*) + printf 'WARN: Unknown option (ignored): %s\n' "$1" >&2 + ;; + *) + break + ;; + esac + + shift + done + + [[ -n "$verbose" ]] && set -o xtrace + + pushd "$SCRIPT_DIR" + + docker build --tag "$IMAGE_REPOSITORY:$IMAGE_TAG" . + + if [[ -n "$push" ]]; then + docker push "$IMAGE_REPOSITORY:$IMAGE_TAG" + fi + + popd +} + +main "$@" diff --git a/chart_test.sh b/chart_test.sh new file mode 100755 index 0000000..8d60f6e --- /dev/null +++ b/chart_test.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash + +# Copyright 2018 The Helm Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +#     http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit +set -o nounset +set -o pipefail + +readonly REPO_ROOT=$(git rev-parse --show-toplevel) +readonly SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + +show_help() { +cat << EOF +Usage: $(basename "$0") + Lint, install, and test Helm charts. + -h, --help Display help + --verbose Display verbose output + --no-lint Skip chart linting + --no-install Skip chart installation + --config Path to the config file (optional) + -- End of all options +EOF +} + +main() { + local no_lint= + local no_install= + local config= + local verbose= + + while :; do + case "${1:-}" in + -h|--help) + show_help + exit + ;; + --verbose) + verbose=true + ;; + --no-install) + no_install=true + ;; + --no-lint) + no_lint=true + ;; + --config) + if [ -n "$2" ]; then + config="$2" + shift + else + echo "ERROR: '--config' cannot be empty." >&2 + exit 1 + fi + ;; + -?*) + echo "WARN: Unknown option (ignored): $1" >&2 + ;; + *) + break + ;; + esac + + shift + done + + if [[ -n "$config" ]]; then + if [[ -f "$config" ]]; then + # shellcheck disable=SC1090 + source "$config" + else + echo "ERROR: Specified config file does not exist: $config" >&2 + exit 1 + fi + fi + + # shellcheck source=lib/chartlib.sh + source "$SCRIPT_DIR/lib/chartlib.sh" + + [[ -n "$verbose" ]] && set -o xtrace + + pushd "$REPO_ROOT" > /dev/null + + local exit_code=0 + + read -ra changed_dirs <<< "$(chartlib::detect_changed_directories)" + + if [[ -n "${changed_dirs[*]}" ]]; then + echo "Charts to be installed and tested: ${changed_dirs[*]}" + + chartlib::init_helm + + local summary=() + + for chart_dir in "${changed_dirs[@]}"; do + echo '' + echo '--------------------------------------------------------------------------------' + echo " Processing chart '$chart_dir'..." + echo '--------------------------------------------------------------------------------' + echo '' + + local error= + + if [[ -z "$no_lint" ]]; then + if ! chartlib::validate_chart "$chart_dir"; then + error=true + fi + if ! chartlib::lint_chart_with_all_configs "$chart_dir"; then + error=true + fi + fi + + if [[ -z "$no_install" && -z "$error" ]]; then + if ! chartlib::install_chart_with_all_configs "$chart_dir"; then + error=true + fi + fi + + if [[ -z "$error" ]]; then + summary+=(" ✔︎ $chart_dir") + else + summary+=(" ✖︎ $chart_dir") + exit_code=1 + fi + done + else + summary+=('No chart changes detected.') + fi + + echo '--------------------------------------------------------------------------------' + for line in "${summary[@]}"; do + echo "$line" + done + echo '--------------------------------------------------------------------------------' + + popd > /dev/null + + exit "$exit_code" +} + +main "$@" diff --git a/etc/chart_schema.yaml b/etc/chart_schema.yaml new file mode 100644 index 0000000..e750223 --- /dev/null +++ b/etc/chart_schema.yaml @@ -0,0 +1,20 @@ +name: str() +home: str() +version: str() +appVersion: any(str(), num()) +description: str() +keywords: list(str(), required=False) +sources: list(str(), required=False) +maintainers: list(include('maintainer'), required=False) +icon: str(required=False) +engine: str(required=False) +condition: str(required=False) +tags: str(required=False) +deprecated: bool(required=False) +kubeVersion: str(required=False) +annotations: map(str(), str(), required=False) +--- +maintainer: + name: str() + email: str(required=False) + url: str(required=False) diff --git a/etc/lintconf.yaml b/etc/lintconf.yaml new file mode 100644 index 0000000..90f48c8 --- /dev/null +++ b/etc/lintconf.yaml @@ -0,0 +1,42 @@ +--- +rules: + braces: + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + brackets: + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + colons: + max-spaces-before: 0 + max-spaces-after: 1 + commas: + max-spaces-before: 0 + min-spaces-after: 1 + max-spaces-after: 1 + comments: + require-starting-space: true + min-spaces-from-content: 2 + document-end: disable + document-start: disable # No --- to start a file + empty-lines: + max: 2 + max-start: 0 + max-end: 0 + hyphens: + max-spaces-after: 1 + indentation: + spaces: consistent + indent-sequences: whatever # - list indentation will handle both indentation and without + check-multi-line-strings: false + key-duplicates: enable + line-length: disable # Lines can be any length + new-line-at-end-of-file: enable + new-lines: + type: unix + trailing-spaces: enable + truthy: + level: warning diff --git a/examples/gke/Dockerfile b/examples/gke/Dockerfile new file mode 100644 index 0000000..e078065 --- /dev/null +++ b/examples/gke/Dockerfile @@ -0,0 +1,30 @@ +# Copyright 2018 The Helm Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +#     http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM gcr.io/kubernetes-charts-ci/chart-testing:v1.0.2 + +ENV PATH /google-cloud-sdk/bin:$PATH +ARG CLOUD_SDK_VERSION=200.0.0 +RUN curl -LO "https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-$CLOUD_SDK_VERSION-linux-x86_64.tar.gz" && \ + tar xzf "google-cloud-sdk-$CLOUD_SDK_VERSION-linux-x86_64.tar.gz" && \ + rm "google-cloud-sdk-$CLOUD_SDK_VERSION-linux-x86_64.tar.gz" && \ + ln -s /lib /lib64 && \ + rm -rf /google-cloud-sdk/.install/.backup && \ + gcloud version + +RUN gcloud config set core/disable_usage_reporting true && \ + gcloud config set component_manager/disable_update_check true && \ + gcloud config set metrics/environment github_docker_image + +WORKDIR /workdir diff --git a/examples/gke/my_test.sh b/examples/gke/my_test.sh new file mode 100755 index 0000000..ce53721 --- /dev/null +++ b/examples/gke/my_test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# Copyright 2018 The Helm Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +#     http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit +set -o nounset +set -o pipefail + +readonly IMAGE_REPOSITORY="myrepo/chart-testing" +readonly REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel)}" + +main() { + local config_container_id + config_container_id=$(docker run -ti -d \ + -v "$GOOGLE_APPLICATION_CREDENTIALS:/service-account.json" \ + -v "$REPO_ROOT:/workdir" \ + -e "BUILD_ID=$PULL_NUMBER" \ + "$IMAGE_REPOSITORY:$IMAGE_TAG" cat) + + # shellcheck disable=SC2064 + trap "docker rm -f $config_container_id" EXIT + + docker exec "$config_container_id" gcloud auth activate-service-account --key-file /service-account.json + docker exec "$config_container_id" gcloud container clusters get-credentials my-cluster --project my-project --zone us-west1-a + docker exec "$config_container_id" kubectl cluster-info + docker exec "$config_container_id" chart_test.sh --config /workdir/.testenv + + echo "Done Testing!" +} + +main diff --git a/lib/chartlib.sh b/lib/chartlib.sh new file mode 100644 index 0000000..433748a --- /dev/null +++ b/lib/chartlib.sh @@ -0,0 +1,472 @@ +#!/usr/bin/env bash + +# Copyright 2018 The Helm Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +#     http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit +set -o nounset +set -o pipefail +shopt -s nullglob + + +readonly REMOTE="${REMOTE:-origin}" +readonly TARGET_BRANCH="${TARGET_BRANCH:-master}" +readonly TIMEOUT="${TIMEOUT:-300}" +readonly LINT_CONF="${LINT_CONF:-/testing/etc/lintconf.yaml}" +readonly CHART_YAML_SCHEMA="${CHART_YAML_SCHEMA:-/testing/etc/chart_schema.yaml}" +readonly VALIDATE_MAINTAINERS="${VALIDATE_MAINTAINERS:-true}" + +# Special handling for arrays +[[ -z "${CHART_DIRS[*]}" ]] && CHART_DIRS=(charts); readonly CHART_DIRS +[[ -z "${EXCLUDED_CHARTS[*]}" ]] && EXCLUDED_CHARTS=(); readonly EXCLUDED_CHARTS +[[ -z "${CHART_REPOS[*]}" ]] && CHART_REPOS=(); readonly CHART_REPOS + +echo +echo '--------------------------------------------------------------------------------' +echo ' Environment:' +echo " REMOTE=$REMOTE" +echo " TARGET_BRANCH=$TARGET_BRANCH" +echo " CHART_DIRS=${CHART_DIRS[*]}" +echo " EXCLUDED_CHARTS=${EXCLUDED_CHARTS[*]}" +echo " CHART_REPOS=${CHART_REPOS[*]}" +echo " TIMEOUT=$TIMEOUT" +echo " LINT_CONF=$LINT_CONF" +echo " CHART_YAML_SCHEMA=$CHART_YAML_SCHEMA" +echo " VALIDATE_MAINTAINERS=$VALIDATE_MAINTAINERS" +echo '--------------------------------------------------------------------------------' +echo + + +# Detects chart directories that have changes against the +# target branch ("$REMOTE/$TARGET_BRANCH"). +chartlib::detect_changed_directories() { + local merge_base + merge_base="$(git merge-base "$REMOTE/$TARGET_BRANCH" HEAD)" + + local changed_dirs=() + local dir + + while read -r dir; do + local excluded= + for excluded_dir in "${EXCLUDED_CHARTS[@]}"; do + if [[ "$dir" == "$excluded_dir" ]]; then + excluded=true + break + fi + done + if [[ -z "$excluded" && -d "$dir" ]]; then + changed_dirs=("${changed_dirs[@]}" "$dir") + fi + done < <(git diff --find-renames --name-only "$merge_base" "${CHART_DIRS[@]}" | awk -F/ '{ print $1"/"$2 }' | uniq) + + echo "${changed_dirs[@]}" +} + +# Initializes the Helm client and add configured repos. +chartlib::init_helm() { + echo 'Initializing Helm client...' + + helm init --client-only + + for repo in "${CHART_REPOS[@]}"; do + local name="${repo%=*}" + local url="${repo#*=}" + + helm repo add "$name" "$url" + done +} + +# Checks a chart for a version bump comparing the version from Chart.yaml +# with that from the target branch. +# Args: +# $1 The chart directory +chartlib::check_for_version_bump() { + local chart_dir="${1?Chart directory is required}" + + echo "Checking chart '$chart_dir' for a version bump..." + + # Check if chart exists on taget branch + if ! git cat-file -e "$REMOTE/$TARGET_BRANCH:$chart_dir/Chart.yaml" > /dev/null 2>&1; then + echo "Unable to find chart on master. New chart detected." + return 0 + fi + + # Compare version of chart under test with that on the target branch + + local old_version + old_version=$(yq -r .version <(git show "$REMOTE/$TARGET_BRANCH:$chart_dir/Chart.yaml")) + echo "Chart version on" "$REMOTE/$TARGET_BRANCH" ":" "$old_version" + + local new_version + new_version=$(yq -r .version "$chart_dir/Chart.yaml") + echo "New chart version: " "$new_version" + + # Pre-releases may not be API compatible. So, when tools compare versions + # they often skip pre-releases. vert can force looking at pre-releases by + # adding a dash on the end followed by pre-release. -0 on the end will force + # looking for all valid pre-releases since a pre-release cannot start with a 0. + # For example, 1.2.3-0 will include looking for pre-releases. + if [[ $old_version == *-* ]]; then # Found the - to denote it has a pre-release + if vert ">$old_version" "$new_version"; then + echo "Chart version ok. Version bumped." + return 0 + fi + else + # No pre-release was found so we increment the patch version and attach a + # -0 to enable pre-releases being found. + local old_version_array + read -ra old_version_array <<< "${old_version//./ }" # Turn the version into an array + + (( old_version_array[2] += 1 )) # Increment the patch release + if vert ">${old_version_array[0]}.${old_version_array[1]}.${old_version_array[2]}-0" "$new_version"; then + echo "Chart version ok. Version bumped." + return 0 + fi + fi + + chartlib::error "Chart version not ok. Needs a version bump." + return 1 +} + +# Validates the Chart.yaml against a YAML schema. +# Args: +# $1 The chart directory +chartlib::validate_chart_yaml() { + local chart_dir="${1?Chart directory is required}" + + echo "Validating Chart.yaml" + yamale --schema "$CHART_YAML_SCHEMA" "$chart_dir/Chart.yaml" +} + +# Validates maintainer names in Chart.yaml to be valid Github users. +# Args: +# $1 The chart directory +chartlib::validate_maintainers() { + local chart_dir="${1?Chart directory is required}" + + echo "Validating maintainers" + + # We require maintainers for non-deprecated charts + local deprecated + deprecated=$(yq -r '.deprecated // empty' "$chart_dir/Chart.yaml") + + local maintainers + maintainers=$(yq -r '.maintainers // empty' "$chart_dir/Chart.yaml") + + if [[ -n "$deprecated" ]]; then + if [[ -n "$maintainers" ]]; then + chartlib::error "Deprecated charts must not have any maintainers in 'Chart.yaml'." + return 1 + else + return 0 + fi + else + if [[ -z "$maintainers" ]]; then + echo "No maintainers found in 'Chart.yaml'." + fi + fi + + while read -r name; do + echo "Verifying maintainer '$name'..." + if [[ $(curl --silent --output /dev/null --write-out "%{http_code}" --fail --head "https://github.com/$name") -ne 200 ]]; then + chartlib::error "'$name' is not a valid GitHub account. Please use a valid Github account to help us communicate with maintainers in PRs/issues." + return 1 + fi + done < <(yq -r '.maintainers[].name' "$chart_dir/Chart.yaml") +} + +# Lints a YAML file. +# Args: +# $1 The YAML file to lint +chartlib::lint_yaml_file() { + local file="${1?Specify YAML file for linting}" + + echo "Linting '$file'..." + + if [[ -f "$file" ]]; then + yamllint --config-file "$LINT_CONF" "$file" + else + chartlib::error "File '$file' does not exist." + return 1 + fi +} + +# Validates a chart: +# - Checks for a version bump +# - Lints Chart.yaml and values.yaml +# - Validates Chart.yaml against schema +# - Validates maintainers +# Args: +# $1 The chart directory +chartlib::validate_chart() { + local chart_dir="${1?Chart directory is required}" + local error= + + echo "Validating chart '$chart_dir'..." + + chartlib::check_for_version_bump "$chart_dir" || error=true + chartlib::lint_yaml_file "$chart_dir/Chart.yaml" || error=true + chartlib::lint_yaml_file "$chart_dir/values.yaml" || error=true + chartlib::validate_chart_yaml "$chart_dir" || error=true + + if [[ "$VALIDATE_MAINTAINERS" == true ]]; then + chartlib::validate_maintainers "$chart_dir" || error=true + fi + + if [[ -n "$error" ]]; then + chartlib::error 'Chart validation failed.' + return 1 + fi +} + +# Lints a chart. +# Args: +# $1 The chart directory +# $2 A custom values file for the chart installation (optional) +chartlib::lint_chart_with_single_config() { + local chart_dir="${1?Chart directory is required}" + local values_file="${2:-}" + + echo "Building dependencies for chart '$chart_dir'..." + helm dependency build "$chart_dir" + + if [[ -n "$values_file" ]]; then + echo "Using custom values file '$values_file'..." + + echo "Linting chart '$chart_dir'..." + helm lint "$chart_dir" --values "$values_file" + else + echo "Chart does not provide test values. Using defaults..." + + echo "Linting chart '$chart_dir'..." + helm lint "$chart_dir" + fi +} + +# Installs and tests a chart. The release and the namespace are +# automatically deleted afterwards. +# Args: +# $1 The chart directory +# $2 The release name for the chart to be installed +# $3 The namespace to install the chart in +# $4 A custom values file for the chart installation (optional) +chartlib::install_chart_with_single_config() { + local chart_dir="${1?Chart directory is required}" + local release="${2?Release is required}" + local namespace="${3?Namespace is required}" + local values_file="${4:-}" + + # Capture subshell output + exec 3>&1 + + if ! ( + set -o errexit + + # Run in subshell so we can use a trap within the function. + trap 'chartlib::print_pod_details_and_logs "$namespace" || true; chartlib::delete_release "$release" || true; chartlib::delete_namespace "$namespace" || true' EXIT + + echo "Building dependencies for chart '$chart_dir'..." + helm dependency build "$chart_dir" + + echo "Installing chart '$chart_dir' into namespace '$namespace'..." + + if [[ -n "$values_file" ]]; then + echo "Using custom values file '$values_file'..." + helm install "$chart_dir" --name "$release" --namespace "$namespace" --wait --timeout "$TIMEOUT" --values "$values_file" + else + echo "Chart does not provide test values. Using defaults..." + helm install "$chart_dir" --name "$release" --namespace "$namespace" --wait --timeout "$TIMEOUT" + fi + + # For deployments --wait may not be sufficient because it looks at 'maxUnavailable' which is 0 by default. + for deployment in $(kubectl get deployment --namespace "$namespace" --output jsonpath='{.items[*].metadata.name}'); do + kubectl rollout status "deployment/$deployment" --namespace "$namespace" + done + + echo "Testing chart '$chart_dir' in namespace '$namespace'..." + helm test "$release" --cleanup --timeout "$TIMEOUT" + + ) >&3; then + + chartlib::error "Chart installation failed: $chart_dir" + return 1 + fi +} + +# Lints a chart for all custom values files matching '*.values.yaml' +# in the 'ci' subdirectory. +# Args: +# $1 The chart directory +chartlib::lint_chart_with_all_configs() { + local chart_dir="${1?Chart directory is required}" + + local has_test_values= + for values_file in "$chart_dir"/ci/*-values.yaml; do + has_test_values=true + chartlib::lint_chart_with_single_config "$chart_dir" "$values_file" + done + + if [[ -z "$has_test_values" ]]; then + chartlib::lint_chart_with_single_config "$chart_dir" + fi +} + +# Installs a chart for all custom values files matching '*.values.yaml' +# in the 'ci' subdirectory. If no custom values files are found, the chart +# is installed with defaults. If $BUILD_ID is set, it is used as +# name for the namespace to install the chart in. Otherwise, the chart +# name is taken as the namespace name. Namespace and release are suffixed with +# an index. Releases and namespaces are automatically deleted afterwards. +# Args: +# $1 The chart directory +chartlib::install_chart_with_all_configs() { + local chart_dir="${1?Chart directory is required}" + local index=0 + + local release + release=$(yq -r .name < "$chart_dir/Chart.yaml") + + local random_suffix + random_suffix=$(tr -dc a-z0-9 < /dev/urandom | fold -w 16 | head -n 1) + + local namespace="${BUILD_ID:-"$release"}-$random_suffix" + local release="$release-$random_suffix" + + local has_test_values= + for values_file in "$chart_dir"/ci/*-values.yaml; do + has_test_values=true + chartlib::install_chart_with_single_config "$chart_dir" "$release-$index" "$namespace-$index" "$values_file" + ((index += 1)) + done + + if [[ -z "$has_test_values" ]]; then + chartlib::install_chart_with_single_config "$chart_dir" "$release" "$namespace" + fi +} + +# Prints log for all pods in the specified namespace. +# Args: +# $1 The namespace +chartlib::print_pod_details_and_logs() { + local namespace="${1?Namespace is required}" + + kubectl get pods --show-all --no-headers --namespace "$namespace" | awk '{ print $1 }' | while read -r pod; do + if [[ -n "$pod" ]]; then + printf '\n================================================================================\n' + printf ' Details from pod %s\n' "$pod" + printf '================================================================================\n' + + printf '\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n' + printf ' Description of pod %s\n' "$pod" + printf '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n' + + kubectl describe pod --namespace "$namespace" "$pod" || true + + printf '\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n' + printf ' End of description for pod %s\n' "$pod" + printf '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n' + + local init_containers + init_containers=$(kubectl get pods --show-all --output jsonpath="{.spec.initContainers[*].name}" --namespace "$namespace" "$pod") + for container in $init_containers; do + printf -- '\n--------------------------------------------------------------------------------\n' + printf ' Logs of init container %s in pod %s\n' "$container" "$pod" + printf -- '--------------------------------------------------------------------------------\n\n' + + kubectl logs --namespace "$namespace" --container "$container" "$pod" || true + + printf -- '\n--------------------------------------------------------------------------------\n' + printf ' End of logs of init container %s in pod %s\n' "$container" "$pod" + printf -- '--------------------------------------------------------------------------------\n' + done + + local containers + containers=$(kubectl get pods --show-all --output jsonpath="{.spec.containers[*].name}" --namespace "$namespace" "$pod") + for container in $containers; do + printf '\n--------------------------------------------------------------------------------\n' + printf -- ' Logs of container %s in pod %s\n' "$container" "$pod" + printf -- '--------------------------------------------------------------------------------\n\n' + + kubectl logs --namespace "$namespace" --container "$container" "$pod" || true + + printf -- '\n--------------------------------------------------------------------------------\n' + printf ' End of logs of container %s in pod %s\n' "$container" "$pod" + printf -- '--------------------------------------------------------------------------------\n' + done + + printf '\n================================================================================\n' + printf ' End of details for pod %s\n' "$pod" + printf '================================================================================\n\n' + fi + done +} + +# Deletes a release. +# Args: +# $1 The name of the release to delete +chartlib::delete_release() { + local release="${1?Release is required}" + + echo "Deleting release '$release'..." + helm delete --purge "$release" --timeout "$TIMEOUT" +} + +# Deletes a namespace. +# Args: +# $1 The namespace to delete +chartlib::delete_namespace() { + local namespace="${1?Namespace is required}" + + echo "Deleting namespace '$namespace'..." + kubectl delete namespace "$namespace" + + echo -n "Waiting for namespace '$namespace' to terminate..." + + local max_retries=30 + local retry=0 + local sleep_time_sec=3 + while ((retry < max_retries)); do + sleep "$sleep_time_sec" + ((retry++)) + + if ! kubectl get namespace "$namespace" &> /dev/null; then + echo + echo "Namespace '$namespace' terminated." + return 0 + fi + + echo -n '.' + done + + echo + + chartlib::error "Namespace '$namespace' not terminated after $((max_retries * sleep_time_sec)) s." + + echo "Force-deleting pods..." + kubectl delete pods --namespace "$namespace" --all --force --grace-period 0 || true + + sleep 3 + + if ! kubectl get namespace "$namespace" &> /dev/null; then + echo "Force-deleting namespace '$namespace'..." + kubectl delete namespace "$namespace" --ignore-not-found --force --grace-period 0 || true + fi +} + +# Logs an error. +# Args: +# $1 The error message +chartlib::error() { + printf '\e[31mERROR: %s\n\e[39m' "$1" >&2 +}