diff --git a/.github/workflows/chart-tooling.yml b/.github/workflows/chart-tooling.yml new file mode 100644 index 0000000..117b6a4 --- /dev/null +++ b/.github/workflows/chart-tooling.yml @@ -0,0 +1,108 @@ +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@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # 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@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # 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 + + # 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})" \ + ${{ steps.charts.outputs.paths }} + + docs: + 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" + 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})" \ + ${{ steps.charts.outputs.paths }} diff --git a/.gitignore b/.gitignore index 8c2ea68..fc19912 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ .serena/cache/ reviews/ + +# Contract working files are local-only in this repository — see CLAUDE.md. +.claude/contracts/ +tests/contract/ + +__pycache__/ 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/CLAUDE.md b/CLAUDE.md index ab7dbf1..ec2332f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,3 +87,30 @@ 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. + +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0ff3c96 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +# 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 +export PRE_COMMIT_VERSION := 4.6.2 + +.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: + @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 + @git diff --exit-code -- $(addsuffix /values.schema.json,$(CHARTS)) $(addsuffix /README.md,$(CHARTS)) diff --git a/README.md b/README.md index 2eee102..39e4ff8 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,49 @@ 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). + +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 +make check +``` + +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: + +| 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..bc2f2e2 --- /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 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/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..4e467a9 --- /dev/null +++ b/gremlin-integrations/values.schema.json @@ -0,0 +1,199 @@ +{ + "$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", + "integer", + "number" + ] + } + } + }, + "imagePullSecrets": { + "type": "array" + }, + "nameOverride": { + "type": "string" + }, + "nodeSelector": { + "type": "object" + }, + "podAnnotations": { + "type": "object" + }, + "podSecurityContext": { + "type": "object" + }, + "replicaCount": { + "type": [ + "integer", + "string" + ] + }, + "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..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: "" @@ -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..d73b0ff 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 - 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..45d926a --- /dev/null +++ b/gremlin/values.schema.json @@ -0,0 +1,903 @@ +{ + "$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", + "string", + "null" + ], + "pattern": "^[0-9]+$" + }, + "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": [ + "integer", + "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", + "integer", + "number" + ] + } + } + }, + "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", + "integer", + "number" + ] + } + } + }, + "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", + "string", + "null" + ], + "pattern": "^[0-9]+$" + }, + "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..f26013d 100644 --- a/gremlin/values.yaml +++ b/gremlin/values.yaml @@ -4,15 +4,15 @@ image: repository: gremlin/gremlin - tag: latest + tag: latest # @schema type:[string, integer, number] pullPolicy: Always - pullSecret: + pullSecret: # @schema type:[string, null] chaoimage: repository: gremlin/chao - tag: latest + tag: latest # @schema type:[string, integer, number] 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, string, null];pattern:^[0-9]+$ # 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,8 +597,8 @@ chao: updateStrategy: type: RollingUpdate rollingUpdate: - maxUnavailable: 25% - maxSurge: 25% + maxUnavailable: 25% # @schema type:[integer, string] + maxSurge: 25% # @schema type:[integer, string] # chao.priorityClassName - # The name of the priority class to use for the Chao deployment @@ -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, string, null];pattern:^[0-9]+$ # 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: diff --git a/hooks/assert-helm-version.sh b/hooks/assert-helm-version.sh new file mode 100755 index 0000000..f09b043 --- /dev/null +++ b/hooks/assert-helm-version.sh @@ -0,0 +1,81 @@ +#!/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/' +} + +# 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="$(_or_empty 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="$(_or_empty _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..55f25c2 --- /dev/null +++ b/hooks/check-drift.sh @@ -0,0 +1,60 @@ +#!/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 - 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. +# +# 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\"] ..." + +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; } + +status="$(git status --porcelain -- "${paths[@]}")" +if [ -z "$status" ]; then + exit 0 +fi + +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 <<< "$status" + +[ -n "$tool" ] && echo "generated by ${tool}" +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 new file mode 100755 index 0000000..7f02207 --- /dev/null +++ b/hooks/helm-docs.sh @@ -0,0 +1,51 @@ +#!/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 +shopt -s nullglob +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..901419c --- /dev/null +++ b/hooks/helm-lint.sh @@ -0,0 +1,43 @@ +#!/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 +shopt -s nullglob +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..626b774 --- /dev/null +++ b/hooks/helm-schema.sh @@ -0,0 +1,42 @@ +#!/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 +shopt -s nullglob +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..4be6c81 --- /dev/null +++ b/hooks/install-schema-plugin.sh @@ -0,0 +1,50 @@ +#!/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 + +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 +# 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. +# +# 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=() +case "$help_out" in + *--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" \ + --version "$SCHEMA_PLUGIN_VERSION" \ + "${verify_flag[@]}" diff --git a/hooks/setup.sh b/hooks/setup.sh new file mode 100755 index 0000000..0344174 --- /dev/null +++ b/hooks/setup.sh @@ -0,0 +1,166 @@ +#!/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}" +PRE_COMMIT_VERSION="${PRE_COMMIT_VERSION:-4.6.2}" +schema_pin="${SCHEMA_PLUGIN_VERSION#v}" + +warn() { + echo "warning: $1" >&2 + shift + for line in "$@"; do + echo " $line" >&2 + 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 + 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}" + 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)" + 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" + 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) -------------------------------------------- +# 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 +# `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 ${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==${PRE_COMMIT_VERSION}"; 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 + : + else + 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 +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 + +# --- 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 ($precommit_status, hooks registered)"