diff --git a/images/node-agent/Dockerfile b/images/node-agent/Dockerfile index c61949f..f82e4cc 100644 --- a/images/node-agent/Dockerfile +++ b/images/node-agent/Dockerfile @@ -35,10 +35,6 @@ RUN arch="$(apk --print-arch | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/')" && chmod 0755 /usr/local/bin/yq \ && yq --version -# kubeconform validates manifests against the Kubernetes and CRD JSON schemas. Not -# packaged for Alpine either, so same treatment as yq: current upstream release, run it -# in the same layer so a truncated download fails the build. -# # Defaults that keep non-interactive agent shells from hanging or failing: # * pagers off — git/kubectl/helm otherwise block on `less` with no TTY # * no git prompts — a private remote without creds fails fast instead of waiting diff --git a/images/node-agent/README.md b/images/node-agent/README.md index 5a5c26d..ed1a0b2 100644 --- a/images/node-agent/README.md +++ b/images/node-agent/README.md @@ -23,8 +23,8 @@ Base: `node:22-alpine` · ~400 MB uncompressed (`kubectl` and `helm` are about | Shell | `bash`, GNU `coreutils`, `findutils`, `diffutils`, `gawk`, `grep`, `sed` | | Text & search | `jq`, `yq`, `ripgrep` (`rg`), `fd`, `less`, `file`, `patch`, `tree` | | Network | `curl`, `wget`, `dig`/`nslookup`/`host` (bind-tools), `nc`, `socat`, `rsync`, `ping`, `openssl` | -| Kubernetes | `helm`, `kubectl`, `kubeconform` (+ vendored schemas) | -| Linting | `yamllint`, `kubeconform` | +| Kubernetes | `helm`, `kubectl` | +| Linting | `yamllint` | | Source control | `git`, `git-lfs`, `ssh`/`scp`/`sftp` | | Archives | `tar`, `gzip`, `xz`, `zip`, `unzip` | | System | `ps`/`top`/`free` (procps-ng), `make`, `su-exec`, `tini`, `tzdata`, `ca-certificates` | @@ -33,40 +33,30 @@ Base: `node:22-alpine` · ~400 MB uncompressed (`kubectl` and `helm` are about Versions track Alpine's repositories and upstream releases at build time — nothing is pinned. Run ` --version` in the image for what a given tag actually shipped. -`yq` and `kubeconform` come from upstream releases because Alpine does not package -them; everything else is an Alpine package. +`yq` comes from upstream releases because Alpine does not package it; everything +else is an Alpine package. -### Schemas for `kubeconform` +### Manifest validation -Both schema sets are **vendored** under `$KUBECONFORM_SCHEMAS`, because the pod that -uses them runs with **no network egress**: - -| Subdirectory | Source | Size | -| --- | --- | --- | -| `builtin/master-standalone-strict/` | [yannh/kubernetes-json-schema](https://github.com/yannh/kubernetes-json-schema) | ~62 MB / 1505 | -| `crds//` | [datree CRDs-catalog](https://github.com/datreeio/CRDs-catalog), only the groups the 50W cluster uses | ~18 MB / 229 | +`yamllint` backs the `yamllint` MCP server in `50W/kube-manifests`, which pairs it +with `kubectl` for Kubernetes manifests: ```sh -kubeconform -strict -ignore-missing-schemas \ - -schema-location "$KUBECONFORM_SCHEMAS/builtin/master-standalone-strict/{{.ResourceKind}}{{.KindSuffix}}.json" \ - -schema-location "$KUBECONFORM_SCHEMAS/crds/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json" \ - manifest.yaml +yamllint -f parsable -d relaxed manifest.yaml +kubectl create --dry-run=client --validate=strict -f manifest.yaml ``` -**Do not add `-schema-location default`.** It is a remote -`raw.githubusercontent.com` URL, so with no egress it fails for *every* kind — -`ConfigMap` included — returning a DNS error rather than a verdict on the manifest. -That failure is invisible on a networked machine, which is why `test.sh` runs the -schema checks in their own `--network none` container. +Worth knowing what that second command does **not** catch. Client-side validation +rejects malformed YAML, a document that is not a Kubernetes object, and a missing +`apiVersion`/`kind` — but it does **not** reject an unknown or renamed field on a CRD +(Flux's `HelmRelease` sets `x-kubernetes-preserve-unknown-fields`), nor a wrong scalar +type. Only `--dry-run=server` catches those, and server-side dry-run is authorized as +a **write**, so it needs `create`/`patch` RBAC that this image's consumers deliberately +do not have. -Why bother: a CRD `kubeconform` has no schema for is reported **`Skipped`**, not -`Invalid`, so a corrupted Flux `HelmRelease` would otherwise sail through. Only the -groups in use are kept; the full catalog is ~211 MB / 4177 schemas. The list lives in -the `Dockerfile` — add a group there and rebuild. - -Groups with no published schema anywhere (`toolhive.stacklok.dev`, `kagent.dev`, -`openclaw.rocks`, `inference.llmkube.dev`) are absent by necessity, so resources of -those kinds report as `Skipped`. Read the summary line — a skip is not a pass. +Client-side validation needs no RBAC at all — it only fetches the OpenAPI schema, +which every authenticated identity may do — but it does need to reach the API server. +With no API access it fails closed on valid manifests (`failed to download openapi`). ## Environment @@ -75,7 +65,6 @@ those kinds report as `Skipped`. Read the summary line — a skip is not a pass. | `PAGER`, `GIT_PAGER` | `cat` | `git`, `kubectl` and `helm` otherwise block on `less` when there is no TTY | | `GIT_TERMINAL_PROMPT` | `0` | a remote needing credentials fails fast instead of hanging on a prompt | | `HELM_CACHE_HOME`, `HELM_CONFIG_HOME`, `HELM_DATA_HOME` | under `/tmp` | lets `helm` run with a read-only root filesystem | -| `KUBECONFORM_SCHEMAS` | `/usr/local/share/kubeconform` | root of the vendored `builtin/` and `crds/` schema trees, so consumers do not hardcode the path | `ENTRYPOINT` is `tini -- docker-entrypoint.sh`, so orphaned grandchildren of a shell command get reaped. `CMD` is `bash`. diff --git a/images/node-agent/test.sh b/images/node-agent/test.sh index 4cf80a6..c5896bd 100755 --- a/images/node-agent/test.sh +++ b/images/node-agent/test.sh @@ -42,9 +42,9 @@ gnu() { } echo "-- present on PATH" -for t in bash sh curl wget jq yq yamllint kubeconform helm kubectl git git-lfs ssh scp \ - dig nslookup host nc socat rsync ping rg fd tar gzip xz zip unzip less file \ - patch tree make ps top free tini su-exec node npm npx openssl base64 env xargs; do +for t in bash sh curl wget jq yq yamllint helm kubectl git git-lfs ssh scp dig nslookup \ + host nc socat rsync ping rg fd tar gzip xz zip unzip less file patch tree make \ + ps top free tini su-exec node npm npx openssl base64 env xargs; do have "$t" done @@ -84,59 +84,6 @@ echo exit "$fail" INNER -# The vendored schemas get their OWN container run, with --network none. -# -# This is not paranoia — it is the check that caught the bug. kubeconform's -# `-schema-location default` is a REMOTE raw.githubusercontent.com URL, so a -# kubeconform test run on a networked machine passes while the real pod (deny-all -# NetworkPolicy) fails on EVERY kind, ConfigMap included, with a DNS error instead of a -# verdict. Only --network none distinguishes "resolves locally" from "quietly -# downloaded it". Note `default` is absent below for the same reason. -echo "==> checking vendored schemas resolve with NO network" -docker run --rm --interactive \ - --user 1000:1000 --read-only --tmpfs /tmp:rw,size=64m --cap-drop ALL \ - --network none --env HOME=/tmp \ - "$REF" bash -s <<'INNER' -set -uo pipefail -fail=0 -ok() { printf ' \033[32mok\033[0m %s\n' "$1"; } -bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=1; } - -[ -d "${KUBECONFORM_SCHEMAS:-/nonexistent}" ] \ - && ok "schema root ${KUBECONFORM_SCHEMAS}" \ - || bad "schema root ${KUBECONFORM_SCHEMAS:-unset} missing" -[ -f "${KUBECONFORM_SCHEMAS}/crds/helm.toolkit.fluxcd.io/helmrelease_v2.json" ] \ - && ok 'HelmRelease v2 CRD schema vendored' \ - || bad 'HelmRelease v2 CRD schema missing' -[ -f "${KUBECONFORM_SCHEMAS}/builtin/master-standalone-strict/configmap-v1.json" ] \ - && ok 'built-in schemas vendored' \ - || bad 'built-in schemas missing' - -kc() { - kubeconform -strict -summary -ignore-missing-schemas \ - -schema-location "${KUBECONFORM_SCHEMAS}/builtin/master-standalone-strict/{{.ResourceKind}}{{.KindSuffix}}.json" \ - -schema-location "${KUBECONFORM_SCHEMAS}/crds/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json" - -} -hr() { printf 'apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: t\nspec:\n interval: 1h\n chart:\n spec:\n chart: c\n %s:\n kind: HelmRepository\n name: r\n' "$1"; } - -if hr sourceRef | kc >/dev/null 2>&1; then ok 'valid HelmRelease passes' -else bad "valid HelmRelease rejected: $(hr sourceRef | kc 2>&1 | tr '\n' ' ')"; fi -# The corruption this exists to catch: a renamed key that is still perfectly valid YAML. -if hr sourceRelease | kc >/dev/null 2>&1; then bad 'renamed key was ACCEPTED' -else ok 'renamed key is rejected'; fi -if printf 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n' | kc >/dev/null 2>&1 -then ok 'built-in kind validates offline' -else bad 'built-in kind failed offline'; fi -# A CRD with no published schema must SKIP, not error, or every toolhive/kagent CR fails. -if printf 'apiVersion: toolhive.stacklok.dev/v1beta1\nkind: MCPServer\nmetadata:\n name: x\n' | kc >/dev/null 2>&1 -then ok 'unknown CRD skips cleanly' -else bad 'unknown CRD errored instead of skipping'; fi - -echo -[ "$fail" = 0 ] && echo "schema checks passed" || echo "FAILURES above" -exit "$fail" -INNER - # The default CMD should land in a usable shell rather than the node REPL. Pass no # arguments so the image's own CMD runs, and feed the script on stdin — the base # image's docker-entrypoint.sh rewrites a leading "-c" into a `node` invocation.