Repository navigation
Validate custom resources against the CRDs a chart ships #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,22 @@ | ||
| #!/usr/bin/env bash | ||
| # Renders every Helm chart under CHARTS_DIR and validates the result with kubeconform: with the | ||
| # default values, and with each <chart>/ci/*-values.yaml, which can turn on what the defaults | ||
| # leave off. Custom resources are validated against the CRDs catalog | ||
| # (https://github.com/datreeio/CRDs-catalog); with the ci values, those that are not in it are | ||
| # skipped, every other resource is still validated strictly. Every chart is evaluated before the | ||
| # result is reported. | ||
| # leave off. Custom resources are validated against the CRDs the chart itself ships - its own | ||
| # crds/ and those of its dependencies - and, for the kinds it defines none, against the CRDs | ||
| # catalog (https://github.com/datreeio/CRDs-catalog); with the ci values, those that are in | ||
| # neither are skipped, every other resource is still validated strictly. Every chart is evaluated | ||
| # before the result is reported. | ||
| # | ||
| # A chart's own CRD comes first because it is the authority for its custom resources: the catalog | ||
| # is generated from whichever CRD version it was last refreshed with, so a field that the operator | ||
| # the chart deploys accepts today can be rejected by a schema built from an older one. | ||
| # | ||
| # Environment: | ||
| # CHARTS_DIR directory that holds one directory per chart (default: charts) | ||
| # HELM helm binary (default: helm) | ||
| # KUBECONFORM kubeconform binary (default: kubeconform) | ||
| # YQ yq binary (mikefarah v4.45.1 or newer - an older one cannot parse the query that | ||
| # reads the CRD versions, and v4.35.2 rejects it outright; default: yq) | ||
| # | ||
| # Run add-helm-repos.sh first for charts with dependencies. | ||
|
|
||
|
|
@@ -18,27 +25,100 @@ set -uo pipefail | |
| CHARTS_DIR="${CHARTS_DIR:-charts}" | ||
| HELM="${HELM:-helm}" | ||
| KUBECONFORM="${KUBECONFORM:-kubeconform}" | ||
| SCHEMAS=(-schema-location default | ||
| -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json') | ||
| YQ="${YQ:-yq}" | ||
|
|
||
| # How kubeconform addresses a schema. It renders the kind in lower case, which is the name the | ||
| # catalog uses and the name write_crd_schemas writes. | ||
| SCHEMA_PATH='{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' | ||
| CATALOG="https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/$SCHEMA_PATH" | ||
|
|
||
| workdir=$(mktemp -d) | ||
| trap 'rm -rf "$workdir"' EXIT | ||
|
|
||
| # write_crd_schemas <chart> <schema dir> <unpack dir> | ||
| # Writes the openAPIV3Schema of every CustomResourceDefinition version the chart ships to | ||
| # <schema dir>/<group>/<kind>_<version>.json. A dependency's CRDs are inside the archive that | ||
| # helm dependency build leaves in <chart>/charts, so those are unpacked first; the chart's own | ||
| # crds/, and a dependency that is already unpacked, are read where they are. | ||
| write_crd_schemas() { | ||
| local chart=$1 schema_dir=$2 unpack_dir=$3 | ||
| local archive crd entries doc group kind index version | ||
|
|
||
| # one line "<document> <group> <kind> <version index> <version>" per schema to write. | ||
| # shellcheck disable=SC2016 # $crd is a yq variable, not a shell one | ||
| local versions_query=' | ||
| select(.kind == "CustomResourceDefinition") | | ||
| [documentIndex, .spec.group, .spec.names.kind] as $crd | | ||
| .spec.versions | to_entries[] | | ||
| select(.value.schema.openAPIV3Schema != null) | | ||
| ($crd + [.key, .value.name]) | join(" ")' | ||
|
|
||
| mkdir -p "$unpack_dir" | ||
| for archive in "$chart"/charts/*.tgz; do | ||
| [ -f "$archive" ] || continue | ||
| tar -xzf "$archive" -C "$unpack_dir" || return 1 | ||
| done | ||
|
|
||
| # the query is read into a variable rather than piped in: the exit status of a process | ||
| # substitution is lost, and an unreadable CRD would then leave no schema and silently send | ||
| # kubeconform back to the catalog - the very failure this works around | ||
| while IFS= read -r crd; do | ||
| entries=$("$YQ" e -N "$versions_query" "$crd") || return 1 | ||
| while read -r doc group kind index version; do | ||
| [ -n "${version:-}" ] || continue | ||
| mkdir -p "$schema_dir/$group" | ||
| "$YQ" e -N -o=json \ | ||
| "select(documentIndex == $doc) | .spec.versions[$index].schema.openAPIV3Schema" \ | ||
| "$crd" > "$schema_dir/$group/${kind,,}_$version.json" || return 1 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. kubeconform's |
||
| done <<< "$entries" | ||
| done < <(crd_files "$chart" "$unpack_dir") | ||
| } | ||
|
|
||
| # crd_files <dir>... | ||
| # Prints every CRD file under the given directories: the *.yaml in a crds/ directory that sits | ||
| # directly in a chart, which is where Helm reads them from. A crds/ elsewhere in a chart is not | ||
| # one - opentelemetry-operator, for instance, keeps Helm templates in conf/crds - and templates | ||
| # are not parseable YAML. | ||
| crd_files() { | ||
| local crd_dir | ||
| while IFS= read -r crd_dir; do | ||
| [ -f "$(dirname "$crd_dir")/Chart.yaml" ] || continue | ||
| find "$crd_dir" -type f \( -name '*.yaml' -o -name '*.yml' \) | ||
| done < <(find "$@" -type d -name crds 2>/dev/null) | ||
| } | ||
|
|
||
| status=0 | ||
| chart_index=0 | ||
| for chart_file in "$CHARTS_DIR"/*/Chart.yaml; do | ||
| [ -f "$chart_file" ] || continue | ||
| chart=$(dirname "$chart_file") | ||
| chart_index=$((chart_index + 1)) | ||
|
|
||
| echo "Evaluating $chart" | ||
| if ! "$HELM" dependency build "$chart"; then | ||
| status=1 | ||
| continue | ||
| fi | ||
| if ! "$HELM" template "$chart" | "$KUBECONFORM" -strict -summary "${SCHEMAS[@]}"; then | ||
|
|
||
| # the dependencies are only on disk once they are built, so their CRDs are collected after | ||
| schema_dir="$workdir/schemas/$chart_index" | ||
| if ! write_crd_schemas "$chart" "$schema_dir" "$workdir/unpacked/$chart_index"; then | ||
| echo "Could not read the CRDs of $chart" >&2 | ||
| status=1 | ||
| continue | ||
| fi | ||
| schemas=(-schema-location default | ||
| -schema-location "$schema_dir/$SCHEMA_PATH" | ||
| -schema-location "$CATALOG") | ||
|
|
||
| if ! "$HELM" template "$chart" | "$KUBECONFORM" -strict -summary "${schemas[@]}"; then | ||
| status=1 | ||
| fi | ||
|
|
||
| for values in "$chart"/ci/*-values.yaml; do | ||
| [ -f "$values" ] || continue | ||
| echo "Evaluating $chart with $(basename "$values")" | ||
| if ! "$HELM" template "$chart" -f "$values" | "$KUBECONFORM" -strict -summary -ignore-missing-schemas "${SCHEMAS[@]}"; then | ||
| if ! "$HELM" template "$chart" -f "$values" | "$KUBECONFORM" -strict -summary -ignore-missing-schemas "${schemas[@]}"; then | ||
| status=1 | ||
| fi | ||
| done | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -76,3 +76,105 @@ EOF | |
| [ "$status" -eq 1 ] | ||
| [[ "$output" == *"Evaluating charts/good with extra-values.yaml"* ]] | ||
| } | ||
|
|
||
| # crd <chart> [<extra string property>] | ||
| # gives the chart a CRD for widgets.example.test, whose spec takes a "size" integer and, when | ||
| # asked for, one more string property. The group is not in the CRDs catalog, so a widget can only | ||
| # validate against the CRD the chart itself ships. | ||
| crd() { | ||
| local extra="" | ||
| [ -z "${2:-}" ] || extra=$(printf '\n %s:\n type: string' "$2") | ||
| mkdir -p "charts/$1/crds" | ||
| cat > "charts/$1/crds/widget.yaml" <<EOF | ||
| apiVersion: apiextensions.k8s.io/v1 | ||
| kind: CustomResourceDefinition | ||
| metadata: | ||
| name: widgets.example.test | ||
| spec: | ||
| group: example.test | ||
| names: | ||
| kind: Widget | ||
| plural: widgets | ||
| scope: Namespaced | ||
| versions: | ||
| - name: v1 | ||
| served: true | ||
| storage: true | ||
| schema: | ||
| openAPIV3Schema: | ||
| type: object | ||
| properties: | ||
| spec: | ||
| type: object | ||
| properties: | ||
| size: | ||
| type: integer$extra | ||
| EOF | ||
| } | ||
|
|
||
| # widget <chart> <spec body> | ||
| widget() { | ||
| cat > "charts/$1/templates/widget.yaml" <<EOF | ||
| apiVersion: example.test/v1 | ||
| kind: Widget | ||
| metadata: | ||
| name: widget | ||
| spec: | ||
| $2 | ||
| EOF | ||
| } | ||
|
|
||
| @test "a custom resource is validated against the CRD its own chart ships" { | ||
| crd good | ||
| widget good " size: 3" | ||
| run "$EVAL" | ||
| [ "$status" -eq 0 ] | ||
| [[ "$output" == *"Chart evaluation successful"* ]] | ||
| } | ||
|
|
||
| @test "a custom resource that the chart's own CRD rejects fails" { | ||
| crd good | ||
| widget good " size: big" | ||
| run "$EVAL" | ||
| [ "$status" -eq 1 ] | ||
| } | ||
|
|
||
| @test "a property the chart's CRD adds is accepted, where the catalog alone would reject it" { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test passes even without the |
||
| crd good colour | ||
| widget good " size: 3 | ||
| colour: red" | ||
| run "$EVAL" | ||
| [ "$status" -eq 0 ] | ||
| } | ||
|
|
||
| @test "a crds directory that is not a chart's own is ignored" { | ||
| # opentelemetry-operator keeps Helm templates in conf/crds; they are not parseable YAML and | ||
| # must not be read as CRDs | ||
| mkdir -p charts/good/conf/crds | ||
| echo '{{- if .Values.x }}' > charts/good/conf/crds/templated.yaml | ||
| run "$EVAL" | ||
| [ "$status" -eq 0 ] | ||
| [[ "$output" != *"bad file"* ]] | ||
| } | ||
|
|
||
| @test "a CRD that cannot be read fails, instead of falling back to the catalog" { | ||
| crd good | ||
| widget good " size: 3" | ||
| # a yq that fails stands for an unparseable CRD, or one a yq too old for the query chokes on: | ||
| # the schema is then missing, and the resource must not simply be validated without it | ||
| YQ=false run "$EVAL" | ||
| [ "$status" -eq 1 ] | ||
| [[ "$output" == *"Could not read the CRDs of charts/good"* ]] | ||
| } | ||
|
|
||
| @test "the CRDs a dependency ships are read from the archive helm builds" { | ||
| chart dep 1.0.0 | ||
| crd dep | ||
| # the widget is in the chart that depends on dep, so its schema can only come from the | ||
| # dep-1.0.0.tgz that helm dependency build puts in charts/user/charts | ||
| chart user 1.0.0 dep 1.0.0 "file://../dep" | ||
| widget user " size: 3" | ||
| run "$EVAL" | ||
| [ "$status" -eq 0 ] | ||
| [[ "$output" == *"Chart evaluation successful"* ]] | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only the top-level
charts/*.tgzare unpacked, but a packaged dependency carries its own subcharts as archives in itscharts/, and Helm installs their CRDs too. An umbrella whose dependency bundles e.g. postgres-operator would still hit the catalog issue; unpack the archives recursively.