From 0715286286ec6de864325aaa7159d957420a97bf Mon Sep 17 00:00:00 2001 From: Danny Seymour Date: Thu, 24 Sep 2026 16:08:29 -0700 Subject: [PATCH 1/7] build(tooling): add lint, schema, and docs scaffolding Makefile targets, pre-commit hooks, and a matrixed GitHub Actions workflow wiring helm lint, values-schema generation, and helm-docs. Neither chart is touched yet - no .schema.yaml or README.md.gotmpl exists, so the schema and docs targets are deliberate no-ops until the following steps land them. make setup installs only what is missing and never replaces a tool that is already present, warning instead when an installed version differs from the pin. The hook scripts warn on the same mismatch rather than failing, since a hard failure there would block commits for exactly the contributor that tolerance exists to accommodate. helm plugin install is not portable across Helm majors - Helm 4 refuses an unverifiable source without --verify=false and Helm 3 has no such flag - so install-schema-plugin.sh probes for the flag rather than parsing a version. The lint and schema jobs are matrixed over both majors with fail-fast disabled, and assert-helm-version.sh fails loudly when a binary is missing rather than silently testing one major twice. The lint override values deliberately use non-PEM placeholders: the chart base64-encodes that material without parsing it, so a placeholder exercises the same render path without putting a private key in a public repository. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L3TrXY8bWRGNv3aRFZZwGZ --- .github/workflows/chart-tooling.yml | 68 +++++++++++++++++ .pre-commit-config.yaml | 23 ++++++ Makefile | 32 ++++++++ README.md | 34 +++++++++ ci/linting/gremlin-integrations.yaml | 28 +++++++ ci/linting/gremlin.yaml | 62 ++++++++++++++++ hooks/assert-helm-version.sh | 73 +++++++++++++++++++ hooks/check-drift.sh | 45 ++++++++++++ hooks/helm-docs.sh | 50 +++++++++++++ hooks/helm-lint.sh | 42 +++++++++++ hooks/helm-schema.sh | 41 +++++++++++ hooks/install-schema-plugin.sh | 28 +++++++ hooks/setup.sh | 105 +++++++++++++++++++++++++++ 13 files changed, 631 insertions(+) create mode 100644 .github/workflows/chart-tooling.yml create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 ci/linting/gremlin-integrations.yaml create mode 100644 ci/linting/gremlin.yaml create mode 100755 hooks/assert-helm-version.sh create mode 100755 hooks/check-drift.sh create mode 100755 hooks/helm-docs.sh create mode 100755 hooks/helm-lint.sh create mode 100755 hooks/helm-schema.sh create mode 100755 hooks/install-schema-plugin.sh create mode 100755 hooks/setup.sh diff --git a/.github/workflows/chart-tooling.yml b/.github/workflows/chart-tooling.yml new file mode 100644 index 0000000..4bd38c9 --- /dev/null +++ b/.github/workflows/chart-tooling.yml @@ -0,0 +1,68 @@ +name: Chart tooling + +on: pull_request + +permissions: + contents: read + +env: + HELM_DOCS_VERSION: 1.14.2 + SCHEMA_PLUGIN_VERSION: v2.2.0 + +jobs: + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + helm: ["v3.17.0", "v4.3.0"] + steps: + - uses: actions/checkout@v4 + + - uses: azure/setup-helm@v4 + with: + version: ${{ matrix.helm }} + + - run: hooks/assert-helm-version.sh ${{ matrix.helm }} + + - run: hooks/helm-lint.sh + + schema: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + helm: ["v3.17.0", "v4.3.0"] + steps: + - uses: actions/checkout@v4 + + - uses: azure/setup-helm@v4 + with: + version: ${{ matrix.helm }} + + - run: hooks/assert-helm-version.sh ${{ matrix.helm }} + + - run: hooks/install-schema-plugin.sh ${{ env.SCHEMA_PLUGIN_VERSION }} + + - run: hooks/helm-schema.sh + + - run: | + hooks/check-drift.sh "values.schema.json" "make schema" \ + --tool "helm schema $(helm plugin list | awk '$1=="schema"{print $2}') (repo pins ${SCHEMA_PLUGIN_VERSION#v})" \ + gremlin/values.schema.json gremlin-integrations/values.schema.json + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - run: | + curl -fsSL "https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin helm-docs + + - run: hooks/helm-docs.sh + + - run: | + hooks/check-drift.sh "README.md" "make docs" \ + --tool "helm-docs $(helm-docs --version | awk '{print $NF}') (repo pins ${HELM_DOCS_VERSION})" \ + gremlin/README.md gremlin-integrations/README.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..649e1b3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: local + hooks: + - id: helm-schema + name: Helm values schema + entry: hooks/helm-schema.sh + language: script + pass_filenames: false + files: ^[^/]+/(values\.yaml|\.schema\.yaml|schemas/.*\.json)$ + + - id: helm-docs + name: Helm docs + entry: hooks/helm-docs.sh + language: script + pass_filenames: false + files: ^[^/]+/(values\.yaml|Chart\.yaml|README\.md\.gotmpl)$ + + - id: helm-lint + name: Helm lint + entry: hooks/helm-lint.sh + language: script + pass_filenames: false + files: ^[^/]+/(templates/.*|values\.yaml|values\.schema\.json|Chart\.yaml)$|^ci/linting/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..11b98c5 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +# Charts are derived, not listed, so a third chart is picked up without +# anyone remembering to edit this file. +CHARTS := $(patsubst %/Chart.yaml,%,$(wildcard */Chart.yaml)) + +export HELM_DOCS_VERSION := 1.14.2 +export SCHEMA_PLUGIN_VERSION := v2.2.0 + +.PHONY: setup lint schema docs test check + +## Install the pinned toolchain and register the pre-commit hooks. Idempotent. +setup: + @hooks/setup.sh + +## helm lint every chart, with defaults and with its ci/linting overrides. +lint: + @hooks/helm-lint.sh + +## Regenerate every chart's values.schema.json from its .schema.yaml. +schema: + @hooks/helm-schema.sh + +## Regenerate every chart's README.md from its README.md.gotmpl + values.yaml. +docs: + @hooks/helm-docs.sh + +## Run the helm unittest suites (same suites .github/workflows/unittest.yml runs). +test: + @$(foreach c,$(CHARTS),helm unittest $(c);) + +## Everything a PR will be checked for, in the order CI runs it. +check: schema docs lint + @git diff --exit-code -- $(addsuffix /values.schema.json,$(CHARTS)) $(addsuffix /README.md,$(CHARTS)) diff --git a/README.md b/README.md index 2eee102..06c09ff 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,40 @@ helm install gremlin-integrations gremlin/gremlin-integrations \ For more detailed instructions, see the chart's documentation [here](https://github.com/gremlin/helm/blob/master/gremlin-integrations/README.md). +## Contributing + +This repo lints, schema-validates, and generates docs for its charts via a `Makefile`. To get set +up: + +```shell +make setup +``` + +This installs the pinned toolchain (`helm-docs`, the `helm schema` plugin, `pre-commit`) and +registers the pre-commit hooks - each idempotently, and it never replaces or upgrades a tool +you've already installed at a different version (you'll get a warning instead, naming the +version found and the version this repo pins). + +Before committing, or at any time: + +```shell +make check +``` + +runs everything a PR is checked for, in the order CI runs it: regenerating each chart's +`values.schema.json` and `README.md`, then linting. The pre-commit hooks run the same steps +automatically on `git commit`. + +Individual targets: + +| Target | What it does | +|---|---| +| `make lint` | `helm lint --strict` on every chart, with defaults and with its `ci/linting` override | +| `make schema` | Regenerates every chart's `values.schema.json` from its `.schema.yaml` | +| `make docs` | Regenerates every chart's `README.md` from its `README.md.gotmpl` | +| `make test` | Runs the `helm unittest` suites | +| `make check` | `schema`, `docs`, `lint`, then fails if regeneration produced an uncommitted diff | + ## Reporting Issues Please report all issues [here](https://support-site.gremlin.com/). diff --git a/ci/linting/gremlin-integrations.yaml b/ci/linting/gremlin-integrations.yaml new file mode 100644 index 0000000..050adb5 --- /dev/null +++ b/ci/linting/gremlin-integrations.yaml @@ -0,0 +1,28 @@ +# hooks/helm-lint.sh's second pass for the gremlin-integrations chart. +# Exercises the paths stock defaults leave dark: a managed secret, the +# client mTLS identity Secret, and the ssl.certFile Secret. +# +# The cert/key values below are deliberately NOT real PEM. The chart only +# base64-encodes them into a Secret without parsing them, so a placeholder +# exercises the "material supplied" render path identically - and keeps a +# private key, throwaway or not, out of a public repository. +gremlin: + secret: + managed: true + type: secret + teamID: "11111111-1111-1111-1111-111111111111" + teamSecret: "00000000-0000-0000-0000-000000000000" + + tls: + identity: + enabled: true + createSecret: + name: gremlin-tls-identity + cert: | + lint-placeholder-certificate-not-real-pem + key: | + lint-placeholder-key-not-real-pem + +ssl: + certFile: | + lint-placeholder-certificate-not-real-pem diff --git a/ci/linting/gremlin.yaml b/ci/linting/gremlin.yaml new file mode 100644 index 0000000..3c5a1b2 --- /dev/null +++ b/ci/linting/gremlin.yaml @@ -0,0 +1,62 @@ +# hooks/helm-lint.sh's second pass for the gremlin chart. Exercises the +# paths stock defaults leave dark: a managed secret, the PodSecurityPolicy +# and SecurityContextConstraints objects, the seccomp profile ConfigMap, the +# client mTLS identity Secrets, the apparmor ConfigMap, and the per-vendor +# GPU DaemonSets. +# +# The cert/key values below are deliberately NOT real PEM. The chart only +# base64-encodes them into a Secret without parsing them, so a placeholder +# exercises the "material supplied" render path identically - and keeps a +# private key, throwaway or not, out of a public repository. +gremlin: + secret: + managed: true + type: secret + teamID: "11111111-1111-1111-1111-111111111111" + clusterID: "lint-cluster" + teamSecret: "00000000-0000-0000-0000-000000000000" + + # installApparmorProfile is deliberately left at its default (false) here: + # gremlin/templates/_helpers.tpl's pspApiVersion helper hard-fails a + # render that combines it with podSecurity.podSecurityPolicy.create + # (verified - helm template exits 1, though helm lint does not surface + # that failure). apparmor alone still exercises the blank-sentinel + # hazard (design.md 7.2, #5) without tripping that combination. + apparmor: unconfined + + podSecurity: + podSecurityPolicy: + create: true + securityContextConstraints: + create: true + seccomp: + enabled: true + profile: "localhost/gremlin" + + tls: + identity: + enabled: true + createSecret: + name: gremlin-tls-identity + cert: | + lint-placeholder-certificate-not-real-pem + key: | + lint-placeholder-key-not-real-pem + + gpu: + enabled: true + +chao: + tls: + identity: + enabled: true + createSecret: + name: chao-tls-identity + cert: | + lint-placeholder-certificate-not-real-pem + key: | + lint-placeholder-key-not-real-pem + +ssl: + certFile: | + lint-placeholder-certificate-not-real-pem diff --git a/hooks/assert-helm-version.sh b/hooks/assert-helm-version.sh new file mode 100755 index 0000000..2320b70 --- /dev/null +++ b/hooks/assert-helm-version.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# The matrix-guard script. A matrix leg (or a job resolving two Helm +# binaries at once) that cannot run must fail, never skip or silently fall +# back to PATH - a silently-halved matrix carries the appearance of +# coverage, which is worse than no matrix at all. +# +# Two usages: +# +# assert-helm-version.sh +# Confirms the `helm` resolved from PATH reports +# exactly. One CI matrix leg, one call: if azure/setup-helm ever +# silently installs something other than what a leg asked for, the leg +# fails with "expected v4.3.0, got v3.17.0" rather than running the v3 +# engine under a check named "(v4.3.0)". +# +# assert-helm-version.sh (no argument) +# Confirms HELM3_BIN and HELM4_BIN each resolve to a real binary +# reporting the expected major version. Never falls back to PATH: CI's +# ambient `helm` is always v3.17.0, so silently letting HELM4_BIN +# default to PATH would resolve the v3 slot twice while looking like +# dual-major coverage. An unset, missing, or wrong-major HELM4_BIN (or +# HELM3_BIN) fails naming that variable specifically. +set -euo pipefail + +_major() { + "$1" version --short 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/' +} + +if [ "$#" -ge 1 ]; then + expected="$1" + if ! command -v helm >/dev/null 2>&1; then + echo "assert-helm-version: no 'helm' found on PATH (expected $expected)" >&2 + exit 1 + fi + got="$(helm version --short 2>/dev/null)" + case "$got" in + "$expected"*) + exit 0 + ;; + *) + echo "assert-helm-version: expected $expected, got ${got:-nothing}" >&2 + exit 1 + ;; + esac +fi + +# Two-binary mode: HELM3_BIN must report major 3, HELM4_BIN must report +# major 4 - neither is ever looked up on PATH here. +fail=0 +for slot in HELM3_BIN:3 HELM4_BIN:4; do + var="${slot%%:*}" + want="${slot##*:}" + val="${!var:-}" + + if [ -z "$val" ]; then + echo "assert-helm-version: $var is not set - refusing to fall back to PATH" >&2 + fail=1 + continue + fi + if [ ! -x "$val" ] && ! command -v "$val" >/dev/null 2>&1; then + echo "assert-helm-version: $var ($val) does not exist or is not executable" >&2 + fail=1 + continue + fi + + major="$(_major "$val")" + if [ "$major" != "$want" ]; then + echo "assert-helm-version: $var ($val) reports major version ${major:-unknown}, expected $want" >&2 + fail=1 + fi +done + +exit $fail diff --git a/hooks/check-drift.sh b/hooks/check-drift.sh new file mode 100755 index 0000000..d07fd27 --- /dev/null +++ b/hooks/check-drift.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Shared regenerate-and-diff reporter, used only by CI (pre-commit has its +# own equivalent built in: a hook that modifies a file blocks the commit). +# +# usage: check-drift.sh [--tool " "] ... +# what - human name of the artifact, e.g. "README.md" +# fix - the command that regenerates it, e.g. "make docs" +# --tool - optional; the generator name+version that produced today's +# output, printed above the diff in the failure BODY, never in +# the ::error title and never asserted on (design.md 4.4) - a +# version mismatch is a rare cause of drift, not the likely one, +# and asserting on it would re-impose through CI the tolerance +# B5 deliberately grants a contributor's unpinned local tool. +# +# The ::error file=...:: annotation is what makes a failure legible without +# opening the job log: GitHub attaches it to the stale file in the PR's +# Files-changed tab, naming which file is stale and what command fixes it. +set -euo pipefail + +usage="usage: check-drift.sh [--tool \"name version\"] ..." + +what="${1:?$usage}" +fix="${2:?$usage}" +shift 2 + +tool="" +if [ "${1:-}" = "--tool" ]; then + tool="${2:?--tool requires a value}" + shift 2 +fi + +paths=("$@") +[ "${#paths[@]}" -gt 0 ] || { echo "$usage" >&2; exit 2; } + +if git diff --quiet -- "${paths[@]}"; then + exit 0 +fi + +while IFS= read -r f; do + echo "::error file=${f}::${what} is out of date. Run '${fix}' and commit the result." +done < <(git diff --name-only -- "${paths[@]}") + +[ -n "$tool" ] && echo "generated by ${tool}" +git --no-pager diff -- "${paths[@]}" +exit 1 diff --git a/hooks/helm-docs.sh b/hooks/helm-docs.sh new file mode 100755 index 0000000..cce2315 --- /dev/null +++ b/hooks/helm-docs.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# B4 local + CI executor. +# +# Regenerates each chart's README.md from its README.md.gotmpl + values.yaml. +# +# Deviation from the original sketch, found by actually running this: a bare +# `helm-docs --chart-search-root=.` regenerates EVERY chart it finds, and a +# chart with no README.md.gotmpl falls back to helm-docs' own default +# template - overwriting a hand-written README wholesale rather than leaving +# it alone. That is not a no-op, it is the single most destructive thing this +# script could do before a chart has adopted a .gotmpl. `--chart-to-generate` +# is what makes "no .gotmpl yet" a genuine no-op: only charts that have one +# are named on that list, mirroring how hooks/helm-schema.sh only touches +# charts that have a .schema.yaml. +# +# Warns, never fails, on a helm-docs version mismatch, for the same reason as +# hooks/helm-schema.sh: B5 tolerates an unpinned existing install, so a +# hard-failing hook here would block `git commit` for exactly the contributor +# B5 exists to accommodate. +set -euo pipefail +cd "$(dirname "$0")/.." # always operate from the repo root + +HELM_DOCS_VERSION="${HELM_DOCS_VERSION:-1.14.2}" + +if ! command -v helm-docs >/dev/null 2>&1; then + echo "helm-docs must be installed and on PATH: https://github.com/norwoodj/helm-docs" >&2 + exit 1 +fi + +found="$(helm-docs --version 2>&1 | awk '{print $NF}')" +if [ "$found" != "$HELM_DOCS_VERSION" ]; then + echo "warning: helm-docs $found found on PATH, but this repo pins $HELM_DOCS_VERSION." >&2 + echo " Leaving it as-is (make setup never replaces an existing install)." >&2 + echo " If 'make docs' produces a README diff that CI disagrees with, this is" >&2 + echo " the first thing to check." >&2 +fi + +charts=() +for chart in */Chart.yaml; do + chart="${chart%/Chart.yaml}" + [ -f "$chart/README.md.gotmpl" ] && charts+=("$chart") +done + +if [ "${#charts[@]}" -eq 0 ]; then + exit 0 +fi + +joined="$(IFS=,; echo "${charts[*]}")" +echo "==> helm-docs --chart-search-root=. --chart-to-generate=$joined" +helm-docs --chart-search-root=. --chart-to-generate="$joined" diff --git a/hooks/helm-lint.sh b/hooks/helm-lint.sh new file mode 100755 index 0000000..0c735f2 --- /dev/null +++ b/hooks/helm-lint.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# B1 local + CI executor. +# +# Two passes per chart: pure defaults (the cheapest possible proof that +# values.yaml satisfies its own values.schema.json, once one exists), and +# with that chart's ci/linting override (if any), which exercises the paths +# defaults leave dark - managed secrets, PSP/SCC, seccomp, mTLS identity, +# GPU vendors, the _validation.tpl mutual-exclusion checks. +# +# --strict promotes warnings to failures; the pre-existing "icon is +# recommended" INFO does not trip it (verified under both Helm majors). +# +# Deliberately no --quiet: it would suppress the output a contributor needs +# to diagnose a failure. +set -euo pipefail +cd "$(dirname "$0")/.." # always operate from the repo root + +if ! command -v helm >/dev/null 2>&1; then + echo "helm must be installed and on PATH: https://helm.sh/docs/intro/install/" >&2 + exit 1 +fi + +status=0 + +for chart in */Chart.yaml; do + chart="${chart%/Chart.yaml}" + + echo "==> helm lint --strict $chart" + if ! helm lint --strict "$chart"; then + status=1 + fi + + override="ci/linting/$chart.yaml" + if [ -f "$override" ]; then + echo "==> helm lint --strict $chart --values $override" + if ! helm lint --strict "$chart" --values "$override"; then + status=1 + fi + fi +done + +exit $status diff --git a/hooks/helm-schema.sh b/hooks/helm-schema.sh new file mode 100755 index 0000000..b335f1d --- /dev/null +++ b/hooks/helm-schema.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# B2 local + CI executor. +# +# Regenerates every chart's values.schema.json from its .schema.yaml. A chart +# with no .schema.yaml is skipped, not failed - that's what keeps this a +# no-op today, before any chart has one. +# +# Warns, never fails, on a schema-plugin version mismatch. B5 explicitly +# tolerates an unpinned existing install; a hard-failing hook here would +# make `git commit` impossible for exactly the contributor B5 accommodates. +# The drift check (hooks/check-drift.sh) is what actually catches a +# consequential divergence. +set -euo pipefail +cd "$(dirname "$0")/.." # always operate from the repo root + +SCHEMA_PLUGIN_VERSION="${SCHEMA_PLUGIN_VERSION:-v2.2.0}" + +if ! command -v helm >/dev/null 2>&1; then + echo "helm must be installed and on PATH: https://helm.sh/docs/intro/install/" >&2 + exit 1 +fi + +installed="$(helm plugin list | awk '$1=="schema"{print $2}')" +pinned="${SCHEMA_PLUGIN_VERSION#v}" +if [ -z "$installed" ]; then + echo "warning: helm-values-schema-json plugin is not installed. Run 'make setup' first." >&2 +elif [ "$installed" != "$pinned" ]; then + echo "warning: helm-values-schema-json plugin $installed found, but this repo pins $pinned." >&2 + echo " Leaving it as-is (make setup never replaces an existing install)." >&2 + echo " If 'make schema' produces a values.schema.json diff that CI disagrees" >&2 + echo " with, this is the first thing to check." >&2 +fi + +for chart in */Chart.yaml; do + chart="${chart%/Chart.yaml}" + [ -f "$chart/.schema.yaml" ] || continue + + echo "==> helm schema --config .schema.yaml ($chart)" + # helm schema resolves input:/output: relative to the working directory. + (cd "$chart" && helm schema --config .schema.yaml) +done diff --git a/hooks/install-schema-plugin.sh b/hooks/install-schema-plugin.sh new file mode 100755 index 0000000..d821aad --- /dev/null +++ b/hooks/install-schema-plugin.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# The one place that knows `helm plugin install` needs `--verify=false` on +# Helm 4 and rejects the flag entirely on Helm 3. Called by hooks/setup.sh +# and by the CI schema job, so the branch exists exactly once. +# +# usage: install-schema-plugin.sh +set -euo pipefail + +SCHEMA_PLUGIN_VERSION="${1:?usage: install-schema-plugin.sh }" +PLUGIN_URL=https://github.com/losisin/helm-values-schema-json + +# Helm 4 added plugin signature verification and refuses an unverifiable +# source by default; Helm 3 has no --verify flag at all. Probe for the flag +# rather than parsing the Helm version: a literal `case "$(helm version)" in +# v4.*)` works today and fails in the unsafe direction on the next major - a +# Helm 5 that keeps --verify would fall through to the default branch, omit +# the flag, and reproduce today's Helm 4 error on a machine nobody has yet. +# Probing degrades safely: a future Helm that renames the flag takes the +# else branch and fails loudly with an install error, rather than guessing +# and proceeding. +verify_flag=() +if helm plugin install --help 2>&1 | grep -q -- '--verify'; then + verify_flag=(--verify=false) +fi + +helm plugin install "$PLUGIN_URL" \ + --version "$SCHEMA_PLUGIN_VERSION" \ + "${verify_flag[@]}" diff --git a/hooks/setup.sh b/hooks/setup.sh new file mode 100755 index 0000000..6d6b558 --- /dev/null +++ b/hooks/setup.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# B5 - installs only what is missing. Never uninstalls, replaces or +# upgrades anything: a tool already present is left exactly as found, +# whatever its version. A version differing from the pin produces a +# warning, not a failure, and this script still exits zero - a bootstrap +# command should not mutate tooling a developer installed deliberately. +# +# Sandboxing seam, load-bearing, do not "simplify" away: this script must +# honour HELM_PLUGINS, HELM_DOCS_INSTALL_DIR and PRE_COMMIT_HOME rather than +# hardcoding paths, and must discover every tool through PATH (never an +# absolute path), so a probe can relocate all three and genuinely test the +# "install a missing tool" branch without touching a real machine. +set -euo pipefail +cd "$(dirname "$0")/.." # always operate from the repo root + +SCHEMA_PLUGIN_VERSION="${SCHEMA_PLUGIN_VERSION:-v2.2.0}" +HELM_DOCS_VERSION="${HELM_DOCS_VERSION:-1.14.2}" +schema_pin="${SCHEMA_PLUGIN_VERSION#v}" + +warn() { + echo "warning: $1" >&2 + shift + for line in "$@"; do + echo " $line" >&2 + done +} + +# --- 1. Helm itself: required, never installed by this script ----------- +if ! command -v helm >/dev/null 2>&1; then + echo "error: helm is not installed. Contributors are expected to already have it:" >&2 + echo " https://helm.sh/docs/intro/install/" >&2 + exit 1 +fi +helm_version="$(helm version --template '{{.Version}}' 2>/dev/null || echo unknown)" + +# --- 2. Schema plugin ----------------------------------------------------- +schema_installed="$(helm plugin list 2>/dev/null | awk '$1=="schema"{print $2}')" +if [ -z "$schema_installed" ]; then + echo "Installing helm-values-schema-json plugin ${SCHEMA_PLUGIN_VERSION}..." + hooks/install-schema-plugin.sh "$SCHEMA_PLUGIN_VERSION" + schema_installed="$(helm plugin list 2>/dev/null | awk '$1=="schema"{print $2}')" + schema_status="pinned" +elif [ "$schema_installed" = "$schema_pin" ]; then + schema_status="pinned" +else + schema_status="MISMATCH" + warn "helm-values-schema-json plugin $schema_installed found, but this repo pins $schema_pin." \ + "Leaving it as-is (make setup never replaces an existing install)." \ + "If 'make schema' produces a values.schema.json diff that CI disagrees with, this" \ + "is the first thing to check. To align: helm plugin uninstall schema && make setup" +fi + +# --- 3. helm-docs ---------------------------------------------------------- +if ! command -v helm-docs >/dev/null 2>&1; then + echo "Installing helm-docs ${HELM_DOCS_VERSION}..." + install_dir="${HELM_DOCS_INSTALL_DIR:-/usr/local/bin}" + mkdir -p "$install_dir" + tmp="$(mktemp -d)" + curl -fsSL "https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/helm-docs_${HELM_DOCS_VERSION}_$(uname -s)_$(uname -m).tar.gz" \ + | tar -xz -C "$tmp" helm-docs + install -m 0755 "$tmp/helm-docs" "$install_dir/helm-docs" + rm -rf "$tmp" + docs_installed="$HELM_DOCS_VERSION" + docs_status="pinned" +else + docs_installed="$(helm-docs --version 2>&1 | awk '{print $NF}')" + if [ "$docs_installed" = "$HELM_DOCS_VERSION" ]; then + docs_status="pinned" + else + docs_status="MISMATCH" + warn "helm-docs $docs_installed found on PATH, but this repo pins $HELM_DOCS_VERSION." \ + "Leaving it as-is (make setup never replaces an existing install)." \ + "If 'make docs' produces a README diff that CI disagrees with, this is the" \ + "first thing to check. To align: brew upgrade norwoodj/tap/helm-docs" \ + "(or see https://github.com/norwoodj/helm-docs/releases/tag/v${HELM_DOCS_VERSION})." + fi +fi + +# --- 4. pre-commit (the binary) -------------------------------------------- +if ! command -v pre-commit >/dev/null 2>&1; then + echo "Installing pre-commit..." + if command -v pip >/dev/null 2>&1; then + pip install pre-commit + elif command -v pip3 >/dev/null 2>&1; then + pip3 install pre-commit + elif command -v brew >/dev/null 2>&1; then + brew install pre-commit + else + echo "error: could not install pre-commit automatically (no pip/pip3/brew on PATH)." >&2 + echo " Install it yourself: https://pre-commit.com/#install" >&2 + exit 1 + fi +fi +precommit_installed="$(pre-commit --version 2>&1 | awk '{print $NF}')" + +# --- 5. Register the hooks -------------------------------------------------- +pre-commit install + +# --- 6. Summary -------------------------------------------------------------- +echo +echo "Toolchain summary:" +echo " helm $helm_version" +echo " helm-values-schema-json ${schema_installed:-none} ($schema_status)" +echo " helm-docs ${docs_installed:-none} ($docs_status)" +echo " pre-commit $precommit_installed (hooks registered)" From 3e7ff1197eb43edd2ff8926677030e1c2020aace Mon Sep 17 00:00:00 2001 From: Danny Seymour Date: Thu, 24 Sep 2026 16:19:18 -0700 Subject: [PATCH 2/7] feat(charts): generate values schemas for both charts Adds a .schema.yaml per chart and the generated values.schema.json, giving chart consumers real values validation at helm lint/template/install time instead of a malformed value failing deep inside template rendering or producing an invalid object that only fails at apply. Thirteen inline @schema annotations correct types the generator cannot infer from a default: blank sentinels that would otherwise be typed null-only and reject any real value, and both maxUnavailable fields, which Kubernetes treats as IntOrString but which infer from whichever literal each workload happens to default to. gremlin.podSecurity.seLinuxOptions cannot be inferred at all - it has no live default keys - so it refs a hand-written fragment. Both the fragment's nullable type and the annotation's sibling type are load-bearing, each for a different Helm major: Helm 3 follows draft-07 and discards keywords beside a $ref, Helm 4 applies them. Dropping either half breaks the chart's own documented BottleRocket configuration on one major while staying green on the other. The two terminationGracePeriodSeconds cases asserting that a string renders literally are superseded - the schema now rejects that input before rendering - and are rewritten to assert the rejection, alongside the existing cases proving a valid integer is accepted, so the field is covered on both sides of the schema boundary. They match on the value path only, since Helm 3 and 4 word the rejection differently. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L3TrXY8bWRGNv3aRFZZwGZ --- gremlin-integrations/.schema.yaml | 17 + gremlin-integrations/values.schema.json | 192 ++++ gremlin-integrations/values.yaml | 6 +- gremlin/.schema.yaml | 17 + gremlin/schemas/selinuxoptions.json | 14 + .../tests/termination_grace_period_test.yaml | 17 +- gremlin/values.schema.json | 888 ++++++++++++++++++ gremlin/values.yaml | 23 +- 8 files changed, 1152 insertions(+), 22 deletions(-) create mode 100644 gremlin-integrations/.schema.yaml create mode 100644 gremlin-integrations/values.schema.json create mode 100644 gremlin/.schema.yaml create mode 100644 gremlin/schemas/selinuxoptions.json create mode 100644 gremlin/values.schema.json diff --git a/gremlin-integrations/.schema.yaml b/gremlin-integrations/.schema.yaml new file mode 100644 index 0000000..346ffc4 --- /dev/null +++ b/gremlin-integrations/.schema.yaml @@ -0,0 +1,17 @@ +input: + - values.yaml + +draft: 2020 +indent: 2 +output: values.schema.json + +useHelmDocs: true + +bundle: true +bundleRoot: "." +bundleWithoutID: false + +schemaRoot: + id: gremlin-integrations + title: Gremlin Integrations Chart Schema + description: Schema for the Gremlin integrations Helm values diff --git a/gremlin-integrations/values.schema.json b/gremlin-integrations/values.schema.json new file mode 100644 index 0000000..14011e5 --- /dev/null +++ b/gremlin-integrations/values.schema.json @@ -0,0 +1,192 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gremlin-integrations", + "title": "Gremlin Integrations Chart Schema", + "description": "Schema for the Gremlin integrations Helm values", + "type": "object", + "properties": { + "affinity": { + "type": "object" + }, + "fullnameOverride": { + "type": "string" + }, + "gremlin": { + "type": "object", + "properties": { + "allowList": { + "type": "string" + }, + "client": { + "type": "object", + "properties": { + "tags": { + "type": "string" + } + } + }, + "extraEnv": { + "type": "array" + }, + "proxy": { + "type": "object", + "properties": { + "url": { + "type": [ + "string", + "null" + ] + } + } + }, + "resources": { + "type": "object" + }, + "secret": { + "type": "object", + "properties": { + "certificate": { + "type": "string" + }, + "key": { + "type": "string" + }, + "managed": { + "type": "boolean" + }, + "teamID": { + "type": "string" + }, + "teamSecret": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "tls": { + "type": "object", + "properties": { + "identity": { + "type": "object", + "properties": { + "createSecret": { + "type": "object", + "properties": { + "cert": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "enabled": { + "type": "boolean" + }, + "existingSecret": { + "type": "object", + "properties": { + "cert": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "remoteSecret": { + "type": "object", + "properties": { + "cert": { + "type": "string" + }, + "key": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "image": { + "type": "object", + "properties": { + "pullPolicy": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "imagePullSecrets": { + "type": "array" + }, + "nameOverride": { + "type": "string" + }, + "nodeSelector": { + "type": "object" + }, + "podAnnotations": { + "type": "object" + }, + "podSecurityContext": { + "type": "object" + }, + "replicaCount": { + "type": "integer" + }, + "securityContext": { + "type": "object" + }, + "serviceAccount": { + "type": "object", + "properties": { + "annotations": { + "type": "object" + }, + "create": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "ssl": { + "type": "object", + "properties": { + "certDir": { + "type": [ + "string", + "null" + ] + }, + "certFile": { + "type": [ + "string", + "null" + ] + } + } + }, + "tolerations": { + "type": "array" + } + } +} diff --git a/gremlin-integrations/values.yaml b/gremlin-integrations/values.yaml index 59d47ed..4bbd727 100644 --- a/gremlin-integrations/values.yaml +++ b/gremlin-integrations/values.yaml @@ -41,11 +41,11 @@ ssl: # Add a certificate file to Gremlin's set of certificate authorities. This argument expects a file containing the # certificate(s) you wish to add. When set, this chart creates secret (`ssl-cert-file`) with the contents and passes # it to both agents. This value is ignored when blank or absent. - certFile: + certFile: # @schema type:[string, null] # ssl.certDir - # sets the SSL_CERT_DIR environment variable on the both agents. Unlike ssl.certFile, this value accepts only a # path to an existing directory on the Kubernetes nodes. This value is ignored when blank or absent. - certDir: + certDir: # @schema type:[string, null] gremlin: # gremlin.resources - @@ -77,7 +77,7 @@ gremlin: # gremlin.proxy.url - # Specifies the http proxy that the Gremlin Agent and Gremlin Kubernetes agent should use to communicate with # api.gremlin.com. This value is ignored when blank or absent. - url: + url: # @schema type:[string, null] # gremlin.tls - # A collection of TLS configurations specific to the Gremlin Integrations Deployment. diff --git a/gremlin/.schema.yaml b/gremlin/.schema.yaml new file mode 100644 index 0000000..d38bbe0 --- /dev/null +++ b/gremlin/.schema.yaml @@ -0,0 +1,17 @@ +input: + - values.yaml + +draft: 2020 +indent: 2 +output: values.schema.json + +useHelmDocs: true + +bundle: true +bundleRoot: "." +bundleWithoutID: false + +schemaRoot: + id: gremlin + title: Gremlin Agent Chart Schema + description: Schema for the Gremlin agent Helm values diff --git a/gremlin/schemas/selinuxoptions.json b/gremlin/schemas/selinuxoptions.json new file mode 100644 index 0000000..9a059a2 --- /dev/null +++ b/gremlin/schemas/selinuxoptions.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "selinuxoptions.json", + "title": "SELinuxOptions", + "description": "SELinux options applied to the Gremlin agent container's securityContext. Only `type` is recommended; the others may relabel paths on the host filesystem, including container runtime sockets and container state directories.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "user": { "type": ["string", "null"], "description": "SELinux user label." }, + "role": { "type": ["string", "null"], "description": "SELinux role label." }, + "type": { "type": ["string", "null"], "description": "SELinux type label — the process label gremlind runs under. On BottleRocket, `super_t`." }, + "level": { "type": ["string", "null"], "description": "SELinux level label (MLS/MCS)." } + } +} diff --git a/gremlin/tests/termination_grace_period_test.yaml b/gremlin/tests/termination_grace_period_test.yaml index 7d1d72c..a81d351 100644 --- a/gremlin/tests/termination_grace_period_test.yaml +++ b/gremlin/tests/termination_grace_period_test.yaml @@ -33,24 +33,23 @@ tests: path: spec.template.spec.terminationGracePeriodSeconds template: daemonset.yaml - - it: should render a non-numeric terminationGracePeriodSeconds on the gremlin DaemonSet as its literal value, not silently coerce it to 0 + - it: should reject a non-numeric terminationGracePeriodSeconds on the gremlin DaemonSet via the values schema set: terminationGracePeriodSeconds: not-a-number asserts: - - equal: - path: spec.template.spec.terminationGracePeriodSeconds - value: not-a-number - template: daemonset.yaml + # Helm 3.17 and Helm 4.x word this rejection differently (design.md 7.5) - match only on + # the value path, which both phrasings name, never on the surrounding wording. + - failedTemplate: + errorPattern: terminationGracePeriodSeconds - - it: should not let a multiline terminationGracePeriodSeconds string inject a sibling pod-spec field on the gremlin DaemonSet + - it: should reject a multiline terminationGracePeriodSeconds string on the gremlin DaemonSet via the values schema, rather than letting it inject a sibling pod-spec field set: terminationGracePeriodSeconds: |- 45 hostIPC: true asserts: - - notExists: - path: spec.template.spec.hostIPC - template: daemonset.yaml + - failedTemplate: + errorPattern: terminationGracePeriodSeconds - it: should render a user-supplied terminationGracePeriodSeconds on the chao Deployment set: diff --git a/gremlin/values.schema.json b/gremlin/values.schema.json new file mode 100644 index 0000000..1768859 --- /dev/null +++ b/gremlin/values.schema.json @@ -0,0 +1,888 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gremlin", + "title": "Gremlin Agent Chart Schema", + "description": "Schema for the Gremlin agent Helm values", + "type": "object", + "properties": { + "affinity": { + "type": "object" + }, + "chao": { + "type": "object", + "properties": { + "affinity": { + "type": "object" + }, + "create": { + "type": "boolean" + }, + "dnsConfig": { + "type": "object" + }, + "envFrom": { + "type": "array" + }, + "excludedNamespaces": { + "type": "array" + }, + "extraEnv": { + "type": "array" + }, + "extraVolumeMounts": { + "type": "array" + }, + "extraVolumes": { + "type": "array" + }, + "features": { + "type": "object", + "properties": { + "dynamicQuery": { + "type": "object", + "properties": { + "allowlist": { + "type": "array", + "items": { + "type": "object", + "properties": { + "apiGroups": { + "type": "array", + "items": { + "type": "string" + } + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "denylist": { + "type": "array" + }, + "enabled": { + "type": "boolean" + } + } + } + } + }, + "hostAliases": { + "type": "array" + }, + "initContainers": { + "type": "array" + }, + "lifecycle": { + "type": "object" + }, + "livenessProbe": { + "type": "object" + }, + "namespaces": { + "type": "array" + }, + "nodeSelector": { + "type": "object" + }, + "podAnnotations": { + "type": "object" + }, + "podLabels": { + "type": "object" + }, + "podSecurityContext": { + "type": "object" + }, + "priorityClassName": { + "type": "string" + }, + "readinessProbe": { + "type": "object" + }, + "resources": { + "type": "object" + }, + "serviceAccount": { + "type": "object", + "properties": { + "annotations": { + "type": "object" + }, + "create": { + "type": "boolean" + } + } + }, + "startupProbe": { + "type": "object" + }, + "terminationGracePeriodSeconds": { + "type": [ + "integer", + "null" + ] + }, + "tls": { + "type": "object", + "properties": { + "identity": { + "type": "object", + "properties": { + "createSecret": { + "type": "object", + "properties": { + "cert": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "enabled": { + "type": "boolean" + }, + "existingSecret": { + "type": "object", + "properties": { + "cert": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "remoteSecret": { + "type": "object", + "properties": { + "cert": { + "type": "string" + }, + "key": { + "type": "string" + } + } + } + } + } + } + }, + "tolerations": { + "type": "array" + }, + "updateStrategy": { + "type": "object", + "properties": { + "rollingUpdate": { + "type": "object", + "properties": { + "maxSurge": { + "type": "string" + }, + "maxUnavailable": { + "type": [ + "integer", + "string" + ] + } + } + }, + "type": { + "type": "string" + } + } + } + } + }, + "chaoimage": { + "type": "object", + "properties": { + "pullPolicy": { + "type": "string" + }, + "pullSecret": { + "type": [ + "string", + "null" + ] + }, + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "containerDrivers": { + "type": "object", + "properties": { + "containerd-linux": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "runtimeSocket": { + "type": "string" + } + } + }, + "crio-linux": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "runtimeSocket": { + "type": "string" + } + } + }, + "docker-linux": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "runtimeSocket": { + "type": "string" + } + } + } + } + }, + "dnsConfig": { + "type": "object" + }, + "envFrom": { + "type": "array" + }, + "extraVolumeMounts": { + "type": "array" + }, + "extraVolumes": { + "type": "array" + }, + "fullnameOverride": { + "type": "string" + }, + "gremlin": { + "type": "object", + "properties": { + "apparmor": { + "type": [ + "string", + "null" + ] + }, + "cgroup": { + "type": "object", + "properties": { + "root": { + "type": "string" + } + } + }, + "client": { + "type": "object", + "properties": { + "tags": { + "type": "string" + } + } + }, + "collect": { + "type": "object", + "properties": { + "dns": { + "type": "boolean" + } + } + }, + "container": { + "type": "object", + "properties": { + "driver": { + "type": "string" + } + } + }, + "dnsPolicy": { + "type": "string" + }, + "extraEnv": { + "type": "array" + }, + "features": { + "type": "object", + "properties": { + "discoverDestinationService": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "pushCIDRTags": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + } + } + }, + "gpu": { + "type": "object", + "properties": { + "amd": { + "type": "object", + "properties": { + "env": { + "type": "array" + }, + "nodeSelector": { + "type": "object", + "properties": { + "amd.com/gpu.present": { + "type": "string" + } + } + }, + "runtimeClassName": { + "type": "string" + }, + "volumeMounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "mountPath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } + } + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "cdiDevice": { + "type": "string" + }, + "custom": { + "type": "object", + "properties": { + "env": { + "type": "array" + }, + "runtimeClassName": { + "type": "string" + }, + "volumeMounts": { + "type": "array" + }, + "volumes": { + "type": "array" + } + } + }, + "enabled": { + "type": "boolean" + }, + "nvidia": { + "description": "- Vendor configuration blocks -------------------------------------------------------- Each block is the full, native-shaped GPU configuration for one vendor. Fields: nodeSelector - node label(s) that identify AND schedule this vendor's nodes runtimeClassName - the RuntimeClass the Gremlin pods run under (\"\" for none) env - environment variables added to the Gremlin container (native env entries). For the NVIDIA container toolkit the capabilities value MUST include `compute` (or `all`) for OpenCL. volumes - pod volumes added to the Gremlin DaemonSet (native volume entries) volumeMounts - container volumeMounts added to the Gremlin container (native mount entries) openclIcd - optional {filename, library}. When set (and projectOpenclIcd is true) the chart projects an /etc/OpenCL/vendors/\u003cfilename\u003e file containing \u003clibrary\u003e, for runtimes that inject the driver library but not its ICD registry file. cdiDevice - optional per-vendor override of gremlin.gpu.cdiDevice NOTE: GPU device nodes (/dev/nvidia*, /dev/kfd, /dev/dri) require the container to have device access. This generally means running privileged (gremlin.podSecurity.privileged: true) or otherwise granting the pod cgroup device permissions. Device access is not required when the NVIDIA container toolkit injects the devices for you. ", + "type": "object", + "properties": { + "env": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "nodeSelector": { + "type": "object", + "properties": { + "nvidia.com/gpu.present": { + "type": "string" + } + } + }, + "openclIcd": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "library": { + "type": "string" + } + } + }, + "runtimeClassName": { + "type": "string" + }, + "volumeMounts": { + "type": "array" + }, + "volumes": { + "type": "array" + } + } + }, + "projectOpenclIcd": { + "type": "boolean" + }, + "vendors": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "hostNetwork": { + "type": "boolean" + }, + "hostPID": { + "type": "boolean" + }, + "installApparmorProfile": { + "type": "boolean" + }, + "podAnnotations": { + "type": "object" + }, + "podLabels": { + "type": "object" + }, + "podSecurity": { + "type": "object", + "properties": { + "allowPrivilegeEscalation": { + "type": "boolean" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "fsGroup": { + "type": "object", + "properties": { + "rule": { + "type": "string" + } + } + }, + "podSecurityPolicy": { + "type": "object", + "properties": { + "create": { + "type": "boolean" + }, + "runAsUser": { + "type": "object", + "properties": { + "rule": { + "type": "string" + } + } + }, + "seLinux": { + "type": "object", + "properties": { + "rule": { + "type": "string" + }, + "seLinuxOptions": { + "type": "object", + "properties": { + "level": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } + } + } + }, + "privileged": { + "type": "boolean" + }, + "readOnlyRootFilesystem": { + "type": "boolean" + }, + "seLinuxOptions": { + "$ref": "schemas/selinuxoptions.json", + "type": [ + "object", + "null" + ] + }, + "seccomp": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "profile": { + "type": "string" + }, + "root": { + "type": "string" + } + } + }, + "securityContextConstraints": { + "type": "object", + "properties": { + "allowHostDirVolumePlugin": { + "type": "boolean" + }, + "create": { + "type": "boolean" + }, + "runAsUser": { + "type": "object", + "properties": { + "type": { + "type": "string" + } + } + }, + "seLinuxContext": { + "type": "object", + "properties": { + "seLinuxOptions": { + "type": "object", + "properties": { + "level": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "type": { + "type": "string" + } + } + } + } + }, + "supplementalGroups": { + "type": "object", + "properties": { + "rule": { + "type": "string" + } + } + }, + "volumes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "priorityClassName": { + "type": "string" + }, + "proxy": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, + "resources": { + "type": "object" + }, + "secret": { + "type": "object", + "properties": { + "certificate": { + "type": "string" + }, + "clusterID": { + "type": "string" + }, + "key": { + "type": "string" + }, + "managed": { + "type": "boolean" + }, + "teamID": { + "type": "string" + }, + "teamSecret": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "serviceAccount": { + "type": "object", + "properties": { + "annotations": { + "type": "object" + }, + "create": { + "type": "boolean" + } + } + }, + "serviceUrl": { + "type": "string" + }, + "tls": { + "type": "object", + "properties": { + "identity": { + "type": "object", + "properties": { + "createSecret": { + "type": "object", + "properties": { + "cert": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "enabled": { + "type": "boolean" + }, + "existingSecret": { + "type": "object", + "properties": { + "cert": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "remoteSecret": { + "type": "object", + "properties": { + "cert": { + "type": "string" + }, + "key": { + "type": "string" + } + } + } + } + } + } + }, + "updateStrategy": { + "type": "object", + "properties": { + "rollingUpdate": { + "type": "object", + "properties": { + "maxUnavailable": { + "type": [ + "integer", + "string" + ] + } + } + }, + "type": { + "type": "string" + } + } + } + } + }, + "hostAliases": { + "type": "array" + }, + "image": { + "type": "object", + "properties": { + "pullPolicy": { + "type": "string" + }, + "pullSecret": { + "type": [ + "string", + "null" + ] + }, + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "initContainers": { + "type": "array" + }, + "lifecycle": { + "type": "object" + }, + "livenessProbe": { + "type": "object" + }, + "nameOverride": { + "type": "string" + }, + "nodeSelector": { + "type": "object" + }, + "podSecurityContext": { + "type": "object" + }, + "readinessProbe": { + "type": "object" + }, + "ssl": { + "type": "object", + "properties": { + "certDir": { + "type": [ + "string", + "null" + ] + }, + "certFile": { + "type": [ + "string", + "null" + ] + } + } + }, + "startupProbe": { + "type": "object" + }, + "terminationGracePeriodSeconds": { + "type": [ + "integer", + "null" + ] + }, + "tolerations": { + "type": "array" + } + }, + "$defs": { + "selinuxoptions.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/selinuxoptions.json", + "title": "SELinuxOptions", + "description": "SELinux options applied to the Gremlin agent container's securityContext. Only `type` is recommended; the others may relabel paths on the host filesystem, including container runtime sockets and container state directories.", + "type": [ + "object", + "null" + ], + "properties": { + "level": { + "description": "SELinux level label (MLS/MCS).", + "type": [ + "string", + "null" + ] + }, + "role": { + "description": "SELinux role label.", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "SELinux type label — the process label gremlind runs under. On BottleRocket, `super_t`.", + "type": [ + "string", + "null" + ] + }, + "user": { + "description": "SELinux user label.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/gremlin/values.yaml b/gremlin/values.yaml index d7c9d37..27496bc 100644 --- a/gremlin/values.yaml +++ b/gremlin/values.yaml @@ -6,13 +6,13 @@ image: repository: gremlin/gremlin tag: latest pullPolicy: Always - pullSecret: + pullSecret: # @schema type:[string, null] chaoimage: repository: gremlin/chao tag: latest pullPolicy: Always - pullSecret: + pullSecret: # @schema type:[string, null] nameOverride: "" fullnameOverride: "" @@ -91,7 +91,7 @@ envFrom: [] # Gremlin DaemonSet only; the Chao deployment has its own chao.terminationGracePeriodSeconds. # Passed through to the rendered manifest as-is. Unset by default, which leaves Kubernetes' # default (30 seconds) in effect. -terminationGracePeriodSeconds: +terminationGracePeriodSeconds: # @schema type:[integer, null] # dnsConfig - # A Kubernetes PodDNSConfig object for the Gremlin DaemonSet's pod spec. Applies to the Gremlin @@ -121,7 +121,7 @@ gremlin: updateStrategy: type: RollingUpdate rollingUpdate: - maxUnavailable: 1 + maxUnavailable: 1 # @schema type:[integer, string] # gremlin.priorityClassName - # The name of the priority class to use for the Gremlin daemonset @@ -141,7 +141,7 @@ gremlin: # # NOTE: Gremlin requires a privileged apparmor profile such as `unconfined` to carry out Process Killer attacks # at the host-level (e.g. any root-owned process) - apparmor: + apparmor: # @schema type:[string, null] collect: # gremlin.collect.dns - @@ -240,7 +240,10 @@ gremlin: # WARNING: Setting anything other than seLinuxOptions.type should be done with caution as these options may relabel paths on the host filesystem, # including container runtime socket files and container state directories. Only the `type` value is recommended to be set here, such as in the # case of using BottleRocket, this should be set to `type: super_t`. - seLinuxOptions: + # The fragment and the sibling type: below must each independently read as "object or + # null" - Helm 3's validator ignores a $ref's sibling keywords and Helm 4's applies them + # as an AND, so both halves are load-bearing, each for a different Helm major. + seLinuxOptions: # @schema $ref: schemas/selinuxoptions.json;type:[object, null] # gremlin.podSecurity.seLinuxOptions.type - # Specifies the process label gremlind processes should run under. When running BottleRocket for example, this should be set to `super_t` # type: super_t @@ -594,7 +597,7 @@ chao: updateStrategy: type: RollingUpdate rollingUpdate: - maxUnavailable: 25% + maxUnavailable: 25% # @schema type:[integer, string] maxSurge: 25% # chao.priorityClassName - @@ -693,7 +696,7 @@ chao: # Chao deployment only; the Gremlin DaemonSet has its own terminationGracePeriodSeconds. # Passed through to the rendered manifest as-is. Unset by default, which leaves Kubernetes' # default (30 seconds) in effect. - terminationGracePeriodSeconds: + terminationGracePeriodSeconds: # @schema type:[integer, null] # chao.dnsConfig - # A Kubernetes PodDNSConfig object for the Chao deployment's pod spec. Applies to the Chao @@ -849,11 +852,11 @@ ssl: # Add a certificate file to Gremlin's set of certificate authorities. This argument expects a file containing the # certificate(s) you wish to add. When set, this chart creates secret (`ssl-cert-file`) with the contents and passes # it to both agents. This value is ignored when blank or absent. - certFile: + certFile: # @schema type:[string, null] # ssl.certDir - # sets the SSL_CERT_DIR environment variable on the both agents. Unlike ssl.certFile, this value accepts only a # path to an existing directory on the Kubernetes nodes. This value is ignored when blank or absent. - certDir: + certDir: # @schema type:[string, null] containerDrivers: docker-linux: From 78e2e44d1292572a2423fad99861213b3a60a042 Mon Sep 17 00:00:00 2001 From: Danny Seymour Date: Thu, 24 Sep 2026 16:32:40 -0700 Subject: [PATCH 3/7] docs(tooling): state reasoning inline instead of citing internal docs This repository is public-facing, and a reference to an internal design document is meaningless to a reader outside the team and rots as soon as that document moves. Restates each rationale directly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L3TrXY8bWRGNv3aRFZZwGZ --- ci/linting/gremlin.yaml | 2 +- gremlin/tests/termination_grace_period_test.yaml | 2 +- hooks/check-drift.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/linting/gremlin.yaml b/ci/linting/gremlin.yaml index 3c5a1b2..bc2f2e2 100644 --- a/ci/linting/gremlin.yaml +++ b/ci/linting/gremlin.yaml @@ -21,7 +21,7 @@ gremlin: # render that combines it with podSecurity.podSecurityPolicy.create # (verified - helm template exits 1, though helm lint does not surface # that failure). apparmor alone still exercises the blank-sentinel - # hazard (design.md 7.2, #5) without tripping that combination. + # hazard without tripping that combination. apparmor: unconfined podSecurity: diff --git a/gremlin/tests/termination_grace_period_test.yaml b/gremlin/tests/termination_grace_period_test.yaml index a81d351..d73b0ff 100644 --- a/gremlin/tests/termination_grace_period_test.yaml +++ b/gremlin/tests/termination_grace_period_test.yaml @@ -37,7 +37,7 @@ tests: set: terminationGracePeriodSeconds: not-a-number asserts: - # Helm 3.17 and Helm 4.x word this rejection differently (design.md 7.5) - match only on + # Helm 3.17 and Helm 4.x word this rejection differently - match only on # the value path, which both phrasings name, never on the surrounding wording. - failedTemplate: errorPattern: terminationGracePeriodSeconds diff --git a/hooks/check-drift.sh b/hooks/check-drift.sh index d07fd27..f011532 100755 --- a/hooks/check-drift.sh +++ b/hooks/check-drift.sh @@ -7,7 +7,7 @@ # fix - the command that regenerates it, e.g. "make docs" # --tool - optional; the generator name+version that produced today's # output, printed above the diff in the failure BODY, never in -# the ::error title and never asserted on (design.md 4.4) - a +# the ::error title and never asserted on - a # version mismatch is a rare cause of drift, not the likely one, # and asserting on it would re-impose through CI the tolerance # B5 deliberately grants a contributor's unpinned local tool. From 930cd7d9b23f2cadf3027b84ef27e7a88ff547cf Mon Sep 17 00:00:00 2001 From: Danny Seymour Date: Thu, 24 Sep 2026 16:45:39 -0700 Subject: [PATCH 4/7] docs: keep agent contract working files out of this repository Agent workflows default to committing their contract, design and validation records as a shared audit trail. That default suits a private repository and not this one: those documents are dense with Jira keys, internal tooling paths and rationale written for people inside the team - precisely what this repo's existing convention against internal references exists to keep out. Gitignores the directory and records the rule, including the reasoning, so the default is not silently reapplied later. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L3TrXY8bWRGNv3aRFZZwGZ --- .gitignore | 3 +++ CLAUDE.md | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.gitignore b/.gitignore index 8c2ea68..b9b9940 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ .serena/cache/ reviews/ + +# Contract working files are local-only in this repository — see CLAUDE.md. +.claude/contracts/ diff --git a/CLAUDE.md b/CLAUDE.md index ab7dbf1..277835b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,3 +87,25 @@ all. this file already does above for the `extraEnv` exemption). Ticket numbers and links to in-progress design artifacts belong in the commit message and PR description, not in repo-committed files. + +## Contract working files stay out of this repository + +Agent workflows that produce a contract, design document, requirements document or validation +records under `.claude/contracts/` must keep those files **local and untracked here**. +`.gitignore` excludes the directory; do not re-include it, and do not commit its contents. + +This overrides the default those workflows ship with, which is to commit the contract as a +shared audit trail. That default is right for a private repository and wrong for this one. + +**Why:** those documents are dense with exactly what the section above forbids — Jira keys, +internal tooling paths, and design rationale written for people inside the team. Committing +them here publishes all of it, and the audit-trail argument for doing so does not outweigh +that when the repository is public. The evidence that matters to an outside reader is the +tests in `tests/`, which stand on their own. + +**How to apply:** let the contract live at `.claude/contracts//` on disk so the tooling +keeps working unchanged, and carry any reasoning a future reader needs into the commit message, +the pull request description, or this file — restated in prose, never as a pointer to a path +that does not exist in the repository. Executable probes under `tests/` are a different matter +and belong in the repository, but they must not cite contract or design-document paths either, +for the same reason. From 3c328591a445b5fbe49ad4c1c838eec0a719e297 Mon Sep 17 00:00:00 2001 From: Danny Seymour Date: Thu, 24 Sep 2026 18:11:57 -0700 Subject: [PATCH 5/7] fix(charts): stop the schema rejecting values that work today Five value shapes rendered before this branch and were rejected after it. Each hard-fails an external operator's next upgrade rather than warning, and each is now accepted again, verified against both Helm majors: image.tag / chaoimage.tag an unquoted tag like 1.2 or 3 is a YAML number, and both charts typed it string-only maxSurge Kubernetes treats it as IntOrString, and the adjacent maxUnavailable was already annotated for exactly that reason replicaCount a quoted "2" terminationGracePeriodSeconds a quoted "45", on both workloads The grace-period keys take a digits-only pattern alongside the widened type, so tolerating a quoted number does not reopen the multiline pod-spec injection the rewritten unit tests exist to close - that payload and a non-numeric string are both still rejected on both majors. The common thread is that the annotations were derived from which defaults are blank, and none asked whether a user might supply a different YAML scalar type than the default. That is the question the schema now answers. Also fixes defects found in the same review: make test returned only the last chart's exit code, so a failing suite passed if a later one succeeded; the drift check used git diff, which cannot see an untracked generated file, so a new chart's schema would be generated, never committed, and reported clean; the CI jobs hardcoded two chart paths where the Makefile derives them; assert-helm-version.sh died silently under set -e before printing the diagnostic that is its entire purpose, and never checked its second binary; and setup.sh could not complete on an ordinary Linux box - no writable-directory check, no aarch64 mapping, a leaked temp dir, and a pip invocation that aborts under PEP 668. The helm-docs download is now checksummed against upstream's published digests before extraction, since a version tag pins a name rather than content and the binary it installs runs on every commit. Third-party actions are pinned to commit SHAs for the same reason, and make setup now says out loud that it installs a plugin with signature verification disabled, which was previously visible only in a source comment. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L3TrXY8bWRGNv3aRFZZwGZ --- .github/workflows/chart-tooling.yml | 52 ++++++++++++++---- Makefile | 7 ++- README.md | 8 +-- gremlin-integrations/values.schema.json | 11 +++- gremlin-integrations/values.yaml | 4 +- gremlin/values.schema.json | 25 +++++++-- gremlin/values.yaml | 10 ++-- hooks/assert-helm-version.sh | 12 ++++- hooks/check-drift.sh | 23 ++++++-- hooks/helm-docs-checksums.txt | 13 +++++ hooks/helm-docs.sh | 1 + hooks/helm-lint.sh | 1 + hooks/helm-schema.sh | 1 + hooks/install-schema-plugin.sh | 18 +++++-- hooks/setup.sh | 71 +++++++++++++++++++++---- 15 files changed, 210 insertions(+), 47 deletions(-) create mode 100644 hooks/helm-docs-checksums.txt diff --git a/.github/workflows/chart-tooling.yml b/.github/workflows/chart-tooling.yml index 4bd38c9..b3507f1 100644 --- a/.github/workflows/chart-tooling.yml +++ b/.github/workflows/chart-tooling.yml @@ -17,9 +17,9 @@ jobs: matrix: helm: ["v3.17.0", "v4.3.0"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: azure/setup-helm@v4 + - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 with: version: ${{ matrix.helm }} @@ -34,9 +34,9 @@ jobs: matrix: helm: ["v3.17.0", "v4.3.0"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: azure/setup-helm@v4 + - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 with: version: ${{ matrix.helm }} @@ -46,23 +46,57 @@ jobs: - run: hooks/helm-schema.sh + # Derived the same way the Makefile derives $(CHARTS), not hardcoded: + # a chart's values.schema.json only gets checked here if this step + # actually finds it. + - id: charts + run: | + shopt -s nullglob + paths=() + for f in */Chart.yaml; do + paths+=("${f%/Chart.yaml}/values.schema.json") + done + echo "paths=${paths[*]}" >> "$GITHUB_OUTPUT" + - run: | hooks/check-drift.sh "values.schema.json" "make schema" \ --tool "helm schema $(helm plugin list | awk '$1=="schema"{print $2}') (repo pins ${SCHEMA_PLUGIN_VERSION#v})" \ - gremlin/values.schema.json gremlin-integrations/values.schema.json + ${{ steps.charts.outputs.paths }} docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - run: | - curl -fsSL "https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz" \ - | sudo tar -xz -C /usr/local/bin helm-docs + asset="helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz" + expected="$(awk -v f="$asset" '$2==f{print $1}' hooks/helm-docs-checksums.txt)" + [ -n "$expected" ] || { echo "no pinned checksum for $asset in hooks/helm-docs-checksums.txt" >&2; exit 1; } + + curl -fsSL "https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/${asset}" -o "/tmp/$asset" + actual="$(sha256sum "/tmp/$asset" | awk '{print $1}')" + if [ "$actual" != "$expected" ]; then + echo "helm-docs checksum mismatch: expected $expected, got $actual" >&2 + exit 1 + fi + + sudo tar -xz -C /usr/local/bin -f "/tmp/$asset" helm-docs - run: hooks/helm-docs.sh + # Derived the same way the Makefile derives $(CHARTS), not hardcoded: + # a chart's README.md only gets checked here if this step actually + # finds it. + - id: charts + run: | + shopt -s nullglob + paths=() + for f in */Chart.yaml; do + paths+=("${f%/Chart.yaml}/README.md") + done + echo "paths=${paths[*]}" >> "$GITHUB_OUTPUT" + - run: | hooks/check-drift.sh "README.md" "make docs" \ --tool "helm-docs $(helm-docs --version | awk '{print $NF}') (repo pins ${HELM_DOCS_VERSION})" \ - gremlin/README.md gremlin-integrations/README.md + ${{ steps.charts.outputs.paths }} diff --git a/Makefile b/Makefile index 11b98c5..0ff3c96 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ CHARTS := $(patsubst %/Chart.yaml,%,$(wildcard */Chart.yaml)) export HELM_DOCS_VERSION := 1.14.2 export SCHEMA_PLUGIN_VERSION := v2.2.0 +export PRE_COMMIT_VERSION := 4.6.2 .PHONY: setup lint schema docs test check @@ -25,7 +26,11 @@ docs: ## Run the helm unittest suites (same suites .github/workflows/unittest.yml runs). test: - @$(foreach c,$(CHARTS),helm unittest $(c);) + @set -e; $(foreach c,$(CHARTS),helm unittest $(c);) + +## schema, docs and lint all read and (re)write the same chart directories; +## running them concurrently under `make -j` would race on that shared state. +.NOTPARALLEL: check ## Everything a PR will be checked for, in the order CI runs it. check: schema docs lint diff --git a/README.md b/README.md index 06c09ff..da95a57 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,11 @@ Before committing, or at any time: make check ``` -runs everything a PR is checked for, in the order CI runs it: regenerating each chart's -`values.schema.json` and `README.md`, then linting. The pre-commit hooks run the same steps -automatically on `git commit`. +runs the same schema, docs and lint checks the `chart-tooling` workflow runs, in that order: +regenerating each chart's `values.schema.json` and `README.md`, then linting. It does not run the +unit test suites - CI checks those separately (`.github/workflows/unittest.yml`), so run +`make test` too before pushing. The pre-commit hooks run `check`'s steps automatically on +`git commit`. Individual targets: diff --git a/gremlin-integrations/values.schema.json b/gremlin-integrations/values.schema.json index 14011e5..4e467a9 100644 --- a/gremlin-integrations/values.schema.json +++ b/gremlin-integrations/values.schema.json @@ -129,7 +129,11 @@ "type": "string" }, "tag": { - "type": "string" + "type": [ + "string", + "integer", + "number" + ] } } }, @@ -149,7 +153,10 @@ "type": "object" }, "replicaCount": { - "type": "integer" + "type": [ + "integer", + "string" + ] }, "securityContext": { "type": "object" diff --git a/gremlin-integrations/values.yaml b/gremlin-integrations/values.yaml index 4bbd727..68807b5 100644 --- a/gremlin-integrations/values.yaml +++ b/gremlin-integrations/values.yaml @@ -2,13 +2,13 @@ # This is a YAML-formatted file. # Declare variables to be passed into your templates. -replicaCount: 1 +replicaCount: 1 # @schema type:[integer, string] image: repository: gremlin/gremlin-integrations pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. - tag: latest + tag: latest # @schema type:[string, integer, number] imagePullSecrets: [ ] nameOverride: "" diff --git a/gremlin/values.schema.json b/gremlin/values.schema.json index 1768859..45d926a 100644 --- a/gremlin/values.schema.json +++ b/gremlin/values.schema.json @@ -124,8 +124,10 @@ "terminationGracePeriodSeconds": { "type": [ "integer", + "string", "null" - ] + ], + "pattern": "^[0-9]+$" }, "tls": { "type": "object", @@ -189,7 +191,10 @@ "type": "object", "properties": { "maxSurge": { - "type": "string" + "type": [ + "integer", + "string" + ] }, "maxUnavailable": { "type": [ @@ -222,7 +227,11 @@ "type": "string" }, "tag": { - "type": "string" + "type": [ + "string", + "integer", + "number" + ] } } }, @@ -787,7 +796,11 @@ "type": "string" }, "tag": { - "type": "string" + "type": [ + "string", + "integer", + "number" + ] } } }, @@ -835,8 +848,10 @@ "terminationGracePeriodSeconds": { "type": [ "integer", + "string", "null" - ] + ], + "pattern": "^[0-9]+$" }, "tolerations": { "type": "array" diff --git a/gremlin/values.yaml b/gremlin/values.yaml index 27496bc..f26013d 100644 --- a/gremlin/values.yaml +++ b/gremlin/values.yaml @@ -4,13 +4,13 @@ image: repository: gremlin/gremlin - tag: latest + tag: latest # @schema type:[string, integer, number] pullPolicy: Always pullSecret: # @schema type:[string, null] chaoimage: repository: gremlin/chao - tag: latest + tag: latest # @schema type:[string, integer, number] pullPolicy: Always pullSecret: # @schema type:[string, null] @@ -91,7 +91,7 @@ envFrom: [] # Gremlin DaemonSet only; the Chao deployment has its own chao.terminationGracePeriodSeconds. # Passed through to the rendered manifest as-is. Unset by default, which leaves Kubernetes' # default (30 seconds) in effect. -terminationGracePeriodSeconds: # @schema type:[integer, null] +terminationGracePeriodSeconds: # @schema type:[integer, string, null];pattern:^[0-9]+$ # dnsConfig - # A Kubernetes PodDNSConfig object for the Gremlin DaemonSet's pod spec. Applies to the Gremlin @@ -598,7 +598,7 @@ chao: type: RollingUpdate rollingUpdate: maxUnavailable: 25% # @schema type:[integer, string] - maxSurge: 25% + maxSurge: 25% # @schema type:[integer, string] # chao.priorityClassName - # The name of the priority class to use for the Chao deployment @@ -696,7 +696,7 @@ chao: # Chao deployment only; the Gremlin DaemonSet has its own terminationGracePeriodSeconds. # Passed through to the rendered manifest as-is. Unset by default, which leaves Kubernetes' # default (30 seconds) in effect. - terminationGracePeriodSeconds: # @schema type:[integer, null] + terminationGracePeriodSeconds: # @schema type:[integer, string, null];pattern:^[0-9]+$ # chao.dnsConfig - # A Kubernetes PodDNSConfig object for the Chao deployment's pod spec. Applies to the Chao diff --git a/hooks/assert-helm-version.sh b/hooks/assert-helm-version.sh index 2320b70..f09b043 100755 --- a/hooks/assert-helm-version.sh +++ b/hooks/assert-helm-version.sh @@ -26,13 +26,21 @@ _major() { "$1" version --short 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/' } +# Wraps a command substitution so a failing $1 (an executable that isn't +# helm, a stub, a wrapper that exits non-zero) can't take the whole script +# down via set -e before its own diagnostic line prints - see the empty-output +# failure this guards against below. +_or_empty() { + "$@" || true +} + if [ "$#" -ge 1 ]; then expected="$1" if ! command -v helm >/dev/null 2>&1; then echo "assert-helm-version: no 'helm' found on PATH (expected $expected)" >&2 exit 1 fi - got="$(helm version --short 2>/dev/null)" + got="$(_or_empty helm version --short 2>/dev/null)" case "$got" in "$expected"*) exit 0 @@ -63,7 +71,7 @@ for slot in HELM3_BIN:3 HELM4_BIN:4; do continue fi - major="$(_major "$val")" + major="$(_or_empty _major "$val")" if [ "$major" != "$want" ]; then echo "assert-helm-version: $var ($val) reports major version ${major:-unknown}, expected $want" >&2 fail=1 diff --git a/hooks/check-drift.sh b/hooks/check-drift.sh index f011532..55f25c2 100755 --- a/hooks/check-drift.sh +++ b/hooks/check-drift.sh @@ -15,6 +15,11 @@ # The ::error file=...:: annotation is what makes a failure legible without # opening the job log: GitHub attaches it to the stale file in the PR's # Files-changed tab, naming which file is stale and what command fixes it. +# +# Uses `git status --porcelain`, not `git diff`: a chart that gains a new +# generated file (e.g. the first values.schema.json for a chart that never +# had one) produces an untracked file, which `git diff` cannot see at all - +# that drift would otherwise pass CI silently. set -euo pipefail usage="usage: check-drift.sh [--tool \"name version\"] ..." @@ -32,14 +37,24 @@ fi paths=("$@") [ "${#paths[@]}" -gt 0 ] || { echo "$usage" >&2; exit 2; } -if git diff --quiet -- "${paths[@]}"; then +status="$(git status --porcelain -- "${paths[@]}")" +if [ -z "$status" ]; then exit 0 fi -while IFS= read -r f; do +while IFS= read -r line; do + f="${line:3}" echo "::error file=${f}::${what} is out of date. Run '${fix}' and commit the result." -done < <(git diff --name-only -- "${paths[@]}") +done <<< "$status" [ -n "$tool" ] && echo "generated by ${tool}" -git --no-pager diff -- "${paths[@]}" +for p in "${paths[@]}"; do + if git ls-files --error-unmatch -- "$p" >/dev/null 2>&1; then + git --no-pager diff -- "$p" + elif [ -e "$p" ]; then + # Untracked (drift generated a file that was never committed): there is + # no tracked blob to diff against, so show it as an addition from /dev/null. + git --no-pager diff --no-index -- /dev/null "$p" || true + fi +done exit 1 diff --git a/hooks/helm-docs-checksums.txt b/hooks/helm-docs-checksums.txt new file mode 100644 index 0000000..cbec1b6 --- /dev/null +++ b/hooks/helm-docs-checksums.txt @@ -0,0 +1,13 @@ +# SHA-256 checksums for the helm-docs release this repo pins +# (HELM_DOCS_VERSION in the Makefile), for the platforms `make setup` and the +# `docs` CI job actually install onto. Sourced from the upstream release's +# own checksums.txt: +# https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/checksums.txt +# +# Bumping HELM_DOCS_VERSION means replacing this whole file from that same +# URL for the new version - hooks/setup.sh and the docs CI job both fail +# loudly with a clear message if an asset they need isn't listed here. +a8cf72ada34fad93285ba2a452b38bdc5bd52cc9a571236244ec31022928d6cc helm-docs_1.14.2_Linux_x86_64.tar.gz +c3787212332386dcd122debef7848feb165aa701467ae3e3442df7638f3ac4e4 helm-docs_1.14.2_Linux_arm64.tar.gz +b2f1ffd0feef8dc0901a38a2053481d1d67b63ca30da4ac774166c6b52fa2245 helm-docs_1.14.2_Darwin_x86_64.tar.gz +2d8399db5b33d240d5f8985241bcf5483563150b968e3229823822979f3e4b8b helm-docs_1.14.2_Darwin_arm64.tar.gz diff --git a/hooks/helm-docs.sh b/hooks/helm-docs.sh index cce2315..7f02207 100755 --- a/hooks/helm-docs.sh +++ b/hooks/helm-docs.sh @@ -18,6 +18,7 @@ # hard-failing hook here would block `git commit` for exactly the contributor # B5 exists to accommodate. set -euo pipefail +shopt -s nullglob cd "$(dirname "$0")/.." # always operate from the repo root HELM_DOCS_VERSION="${HELM_DOCS_VERSION:-1.14.2}" diff --git a/hooks/helm-lint.sh b/hooks/helm-lint.sh index 0c735f2..901419c 100755 --- a/hooks/helm-lint.sh +++ b/hooks/helm-lint.sh @@ -13,6 +13,7 @@ # Deliberately no --quiet: it would suppress the output a contributor needs # to diagnose a failure. set -euo pipefail +shopt -s nullglob cd "$(dirname "$0")/.." # always operate from the repo root if ! command -v helm >/dev/null 2>&1; then diff --git a/hooks/helm-schema.sh b/hooks/helm-schema.sh index b335f1d..626b774 100755 --- a/hooks/helm-schema.sh +++ b/hooks/helm-schema.sh @@ -11,6 +11,7 @@ # The drift check (hooks/check-drift.sh) is what actually catches a # consequential divergence. set -euo pipefail +shopt -s nullglob cd "$(dirname "$0")/.." # always operate from the repo root SCHEMA_PLUGIN_VERSION="${SCHEMA_PLUGIN_VERSION:-v2.2.0}" diff --git a/hooks/install-schema-plugin.sh b/hooks/install-schema-plugin.sh index d821aad..abea19c 100755 --- a/hooks/install-schema-plugin.sh +++ b/hooks/install-schema-plugin.sh @@ -9,6 +9,11 @@ set -euo pipefail SCHEMA_PLUGIN_VERSION="${1:?usage: install-schema-plugin.sh }" PLUGIN_URL=https://github.com/losisin/helm-values-schema-json +if ! command -v helm >/dev/null 2>&1; then + echo "helm must be installed and on PATH: https://helm.sh/docs/intro/install/" >&2 + exit 1 +fi + # Helm 4 added plugin signature verification and refuses an unverifiable # source by default; Helm 3 has no --verify flag at all. Probe for the flag # rather than parsing the Helm version: a literal `case "$(helm version)" in @@ -18,10 +23,17 @@ PLUGIN_URL=https://github.com/losisin/helm-values-schema-json # Probing degrades safely: a future Helm that renames the flag takes the # else branch and fails loudly with an install error, rather than guessing # and proceeding. +# +# Captured to a variable and matched with a `case`, not piped into +# `grep -q`: `grep -q` exits the instant it matches, and if `helm`'s help +# output ever exceeded the pipe buffer it would be killed by SIGPIPE: under +# `set -o pipefail` that reads as "flag not found" and silently drops +# --verify=false - failing in the direction this script exists to prevent. +help_out="$(helm plugin install --help 2>&1 || true)" verify_flag=() -if helm plugin install --help 2>&1 | grep -q -- '--verify'; then - verify_flag=(--verify=false) -fi +case "$help_out" in + *--verify*) verify_flag=(--verify=false) ;; +esac helm plugin install "$PLUGIN_URL" \ --version "$SCHEMA_PLUGIN_VERSION" \ diff --git a/hooks/setup.sh b/hooks/setup.sh index 6d6b558..2e84d98 100755 --- a/hooks/setup.sh +++ b/hooks/setup.sh @@ -15,6 +15,7 @@ cd "$(dirname "$0")/.." # always operate from the repo root SCHEMA_PLUGIN_VERSION="${SCHEMA_PLUGIN_VERSION:-v2.2.0}" HELM_DOCS_VERSION="${HELM_DOCS_VERSION:-1.14.2}" +PRE_COMMIT_VERSION="${PRE_COMMIT_VERSION:-4.6.2}" schema_pin="${SCHEMA_PLUGIN_VERSION#v}" warn() { @@ -25,6 +26,15 @@ warn() { done } +# Portable sha256: Linux ships sha256sum, macOS ships shasum -a 256. +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + # --- 1. Helm itself: required, never installed by this script ----------- if ! command -v helm >/dev/null 2>&1; then echo "error: helm is not installed. Contributors are expected to already have it:" >&2 @@ -53,13 +63,44 @@ fi # --- 3. helm-docs ---------------------------------------------------------- if ! command -v helm-docs >/dev/null 2>&1; then echo "Installing helm-docs ${HELM_DOCS_VERSION}..." + install_dir="${HELM_DOCS_INSTALL_DIR:-/usr/local/bin}" - mkdir -p "$install_dir" + if ! mkdir -p "$install_dir" 2>/dev/null || [ ! -w "$install_dir" ]; then + echo "error: cannot write to $install_dir." >&2 + echo " Set HELM_DOCS_INSTALL_DIR to a directory you can write (e.g. a" >&2 + echo " directory already on your PATH under \$HOME), or re-run 'make setup'" >&2 + echo " with sudo." >&2 + exit 1 + fi + + arch="$(uname -m)" + case "$arch" in + # helm-docs publishes Linux/Darwin_arm64; Linux's uname -m says aarch64. + aarch64) arch="arm64" ;; + esac + asset="helm-docs_${HELM_DOCS_VERSION}_$(uname -s)_${arch}.tar.gz" + + checksums="hooks/helm-docs-checksums.txt" + expected="$(awk -v f="$asset" '$2==f{print $1}' "$checksums")" + if [ -z "$expected" ]; then + echo "error: no pinned checksum for $asset in $checksums." >&2 + echo " Add one from https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/checksums.txt" >&2 + exit 1 + fi + tmp="$(mktemp -d)" - curl -fsSL "https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/helm-docs_${HELM_DOCS_VERSION}_$(uname -s)_$(uname -m).tar.gz" \ - | tar -xz -C "$tmp" helm-docs + trap 'rm -rf "$tmp"' EXIT + + curl -fsSL "https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/${asset}" -o "$tmp/$asset" + actual="$(sha256 "$tmp/$asset")" + if [ "$actual" != "$expected" ]; then + echo "error: helm-docs download checksum mismatch for $asset." >&2 + echo " expected $expected, got $actual. Refusing to install." >&2 + exit 1 + fi + + tar -xz -C "$tmp" -f "$tmp/$asset" helm-docs install -m 0755 "$tmp/helm-docs" "$install_dir/helm-docs" - rm -rf "$tmp" docs_installed="$HELM_DOCS_VERSION" docs_status="pinned" else @@ -77,16 +118,24 @@ else fi # --- 4. pre-commit (the binary) -------------------------------------------- +# pipx first, and --user on a bare pip/pip3: a PEP 668 "externally-managed- +# environment" system Python (stock on current Fedora, Debian, Homebrew) +# refuses an unqualified `pip install`. Each candidate is tried inside the +# `if`'s own condition, not as a standalone command, so a failing pip/pip3 +# falls through to the next candidate instead of aborting the script under +# set -e before brew ever gets a chance. if ! command -v pre-commit >/dev/null 2>&1; then echo "Installing pre-commit..." - if command -v pip >/dev/null 2>&1; then - pip install pre-commit - elif command -v pip3 >/dev/null 2>&1; then - pip3 install pre-commit - elif command -v brew >/dev/null 2>&1; then - brew install pre-commit + if command -v pipx >/dev/null 2>&1 && pipx install pre-commit; then + : + elif command -v pip >/dev/null 2>&1 && pip install --user pre-commit; then + : + elif command -v pip3 >/dev/null 2>&1 && pip3 install --user pre-commit; then + : + elif command -v brew >/dev/null 2>&1 && brew install pre-commit; then + : else - echo "error: could not install pre-commit automatically (no pip/pip3/brew on PATH)." >&2 + echo "error: could not install pre-commit automatically (tried pipx, pip --user, pip3 --user, brew)." >&2 echo " Install it yourself: https://pre-commit.com/#install" >&2 exit 1 fi From 91feb86e37076848adaef181db2871253f505ed6 Mon Sep 17 00:00:00 2001 From: Danny Seymour Date: Thu, 24 Sep 2026 18:15:05 -0700 Subject: [PATCH 6/7] build(tooling): say what setup installs, and pin what it claims to pin The plugin install disables Helm 4's signature verification because upstream publishes nothing signed to verify against. That is defensible, but it was visible only in a source comment - someone running make setup saw a bland "Installing..." line and no indication that third-party code was arriving from a mutable reference with verification off. It now says so where the decision is made, and the README explains why. pre-commit was the one member of the toolchain the README described as pinned that was installed unpinned, so the claim is now true rather than aspirational. Also stops checkout persisting its credentials in .git/config; no job here pushes anything. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L3TrXY8bWRGNv3aRFZZwGZ --- .github/workflows/chart-tooling.yml | 6 ++++++ .gitignore | 2 ++ README.md | 7 +++++++ hooks/install-schema-plugin.sh | 12 +++++++++++- hooks/setup.sh | 22 +++++++++++++++++----- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/workflows/chart-tooling.yml b/.github/workflows/chart-tooling.yml index b3507f1..117b6a4 100644 --- a/.github/workflows/chart-tooling.yml +++ b/.github/workflows/chart-tooling.yml @@ -18,6 +18,8 @@ jobs: helm: ["v3.17.0", "v4.3.0"] steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 with: @@ -35,6 +37,8 @@ jobs: helm: ["v3.17.0", "v4.3.0"] steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 with: @@ -67,6 +71,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - run: | asset="helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz" diff --git a/.gitignore b/.gitignore index b9b9940..51aba6a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ reviews/ # Contract working files are local-only in this repository — see CLAUDE.md. .claude/contracts/ + +__pycache__/ diff --git a/README.md b/README.md index da95a57..39e4ff8 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,13 @@ registers the pre-commit hooks - each idempotently, and it never replaces or upg you've already installed at a different version (you'll get a warning instead, naming the version found and the version this repo pins). +The `helm schema` plugin install disables Helm 4's plugin signature verification +(`--verify=false`): its upstream, [losisin/helm-values-schema-json](https://github.com/losisin/helm-values-schema-json), +publishes no signed release for Helm to verify against, so this is the only way to install it at +all, not a shortcut taken instead of a safer option. `make setup` prints this at install time too. +The `helm-docs` download is checksummed against a pinned SHA-256 before it's extracted, so that +one isn't in the same position. + Before committing, or at any time: ```shell diff --git a/hooks/install-schema-plugin.sh b/hooks/install-schema-plugin.sh index abea19c..4be6c81 100755 --- a/hooks/install-schema-plugin.sh +++ b/hooks/install-schema-plugin.sh @@ -32,7 +32,17 @@ fi help_out="$(helm plugin install --help 2>&1 || true)" verify_flag=() case "$help_out" in - *--verify*) verify_flag=(--verify=false) ;; + *--verify*) + verify_flag=(--verify=false) + # Visible at the point of the decision, not just in this comment: + # upstream publishes no signed release for Helm to check --verify + # against, so this is the only way to install the plugin at all, not + # a shortcut taken instead of a safer option. Whoever runs `make + # setup` should see this, not just whoever reads this file's source. + echo "Installing helm-values-schema-json from $PLUGIN_URL with plugin" + echo "signature verification disabled (--verify=false): upstream publishes no" + echo "signed release for Helm to verify against." + ;; esac helm plugin install "$PLUGIN_URL" \ diff --git a/hooks/setup.sh b/hooks/setup.sh index 2e84d98..0344174 100755 --- a/hooks/setup.sh +++ b/hooks/setup.sh @@ -118,6 +118,11 @@ else fi # --- 4. pre-commit (the binary) -------------------------------------------- +# pipx/pip/pip3 install the exact pin; brew does not take a version pin any +# more easily than helm-docs' brew path does above, so it installs whatever +# is current and is reconciled the same way: a mismatch warning below, never +# a failure. +# # pipx first, and --user on a bare pip/pip3: a PEP 668 "externally-managed- # environment" system Python (stock on current Fedora, Debian, Homebrew) # refuses an unqualified `pip install`. Each candidate is tried inside the @@ -125,12 +130,12 @@ fi # falls through to the next candidate instead of aborting the script under # set -e before brew ever gets a chance. if ! command -v pre-commit >/dev/null 2>&1; then - echo "Installing pre-commit..." - if command -v pipx >/dev/null 2>&1 && pipx install pre-commit; then + echo "Installing pre-commit ${PRE_COMMIT_VERSION}..." + if command -v pipx >/dev/null 2>&1 && pipx install "pre-commit==${PRE_COMMIT_VERSION}"; then : - elif command -v pip >/dev/null 2>&1 && pip install --user pre-commit; then + elif command -v pip >/dev/null 2>&1 && pip install --user "pre-commit==${PRE_COMMIT_VERSION}"; then : - elif command -v pip3 >/dev/null 2>&1 && pip3 install --user pre-commit; then + elif command -v pip3 >/dev/null 2>&1 && pip3 install --user "pre-commit==${PRE_COMMIT_VERSION}"; then : elif command -v brew >/dev/null 2>&1 && brew install pre-commit; then : @@ -141,6 +146,13 @@ if ! command -v pre-commit >/dev/null 2>&1; then fi fi precommit_installed="$(pre-commit --version 2>&1 | awk '{print $NF}')" +if [ "$precommit_installed" = "$PRE_COMMIT_VERSION" ]; then + precommit_status="pinned" +else + precommit_status="MISMATCH" + warn "pre-commit $precommit_installed found, but this repo pins $PRE_COMMIT_VERSION." \ + "Leaving it as-is (make setup never replaces an existing install)." +fi # --- 5. Register the hooks -------------------------------------------------- pre-commit install @@ -151,4 +163,4 @@ echo "Toolchain summary:" echo " helm $helm_version" echo " helm-values-schema-json ${schema_installed:-none} ($schema_status)" echo " helm-docs ${docs_installed:-none} ($docs_status)" -echo " pre-commit $precommit_installed (hooks registered)" +echo " pre-commit $precommit_installed ($precommit_status, hooks registered)" From e6cff8700eb2da986311ad6601d6e650aa04ea40 Mon Sep 17 00:00:00 2001 From: Danny Seymour Date: Thu, 24 Sep 2026 19:17:09 -0700 Subject: [PATCH 7/7] docs: keep agent contract probes out of this repository The probes an agent workflow generates to grade its own work belong with the contract they serve, not in this repository. They grade one piece of work against one agreement: nothing here invokes them, they need two Helm majors on PATH to run at all, and their headers are written in the contract's vocabulary rather than in terms a reader of this repository would recognise. Gitignores them alongside the contract and records the reasoning, so the default of committing them is not silently reapplied later. What this repository does run is unaffected: the helm unittest suites under each chart's tests/ directory, which CI executes on every pull request. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L3TrXY8bWRGNv3aRFZZwGZ --- .gitignore | 1 + CLAUDE.md | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 51aba6a..fc19912 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ reviews/ # Contract working files are local-only in this repository — see CLAUDE.md. .claude/contracts/ +tests/contract/ __pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md index 277835b..ec2332f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,11 @@ tests in `tests/`, which stand on their own. **How to apply:** let the contract live at `.claude/contracts//` on disk so the tooling keeps working unchanged, and carry any reasoning a future reader needs into the commit message, the pull request description, or this file — restated in prose, never as a pointer to a path -that does not exist in the repository. Executable probes under `tests/` are a different matter -and belong in the repository, but they must not cite contract or design-document paths either, -for the same reason. +that does not exist in the repository. + +The same applies to the executable probes those workflows generate, which live under +`tests/contract/` and are gitignored alongside the contract. They are machinery for grading one +piece of work against one agreement, not a suite this repository runs: nothing invokes them, they +need two Helm majors on `PATH` to run at all, and their headers are written in the contract's own +vocabulary. `tests/` otherwise remains the right place for tests this repository does run — +`helm unittest` suites under `/tests/` are committed and are what CI executes.