|
1 | | -# VictoriaMetrics/helm-charts#2882 — Operator admissionWebhooks Aren't Working As Expected |
| 1 | +# VictoriaMetrics/helm-charts#2882 — admissionWebhooks accept malformed manifests |
2 | 2 |
|
3 | | -**Report:** user installs `victoria-metrics-operator` chart v0.62.1. A `VMAgent` manifest containing a non-existent field (`test: true`) is accepted by the cluster. With the upstream `install-with-webhook.yaml` from the operator repo, the same manifest is rejected. |
| 3 | +## H₀ (observation) |
| 4 | +Helm chart's admission webhooks accept malformed VMAgent manifests (e.g. `spec.test: true`); upstream `install-with-webhook.yaml` from the operator release rejects the same input. Both ship the same operator binary (v0.70.0). |
4 | 5 |
|
5 | | -## Diagnosis |
6 | | - |
7 | | -The bad manifest is silently pruned/accepted because the helm chart, by default, installs **specless CRDs** — every CRD's `spec.spec` schema is just `{ type: object, x-kubernetes-preserve-unknown-fields: true }`. The kube-apiserver's strict field validation (which is what actually rejects `test: true` in the upstream manifest) is bypassed: with `preserveUnknownFields: true` every key under `spec.*` is allowed. |
8 | | - |
9 | | -The webhook **is** running and reachable; the webhook config that helm renders is wired correctly (path, port, CA, service all match what the operator expects). The webhook just doesn't catch this kind of error: the VMAgent validator (`internal/webhook/operator/v1beta1/vmagent_webhook.go`) only checks `obj.Status.ParsingSpecError` (empty at CREATE time) and `obj.Validate()` (typed struct validation — unknown fields were already dropped by the JSON decode). It has no way to see `test: true`. |
10 | | - |
11 | | -The upstream `install-with-webhook.yaml` works because it ships the **full** openAPIV3Schema (~3000 lines per CRD). The apiserver's structural schema check rejects unknown fields under `spec` before the webhook ever runs. |
12 | | - |
13 | | -## Why the chart is shaped this way |
| 6 | +## H₁ — webhook routing broken in chart (KILLED, deduction) |
| 7 | +**Perturbation**: `helm template charts/victoria-metrics-operator/`; diff rendered Service/Deployment/ValidatingWebhookConfiguration against upstream `install-with-webhook.yaml`. |
14 | 8 |
|
15 | | -- Commit `0f6a51d19f` / PR [#2419](https://github.com/VictoriaMetrics/helm-charts/pull/2419) made the templated CRDs specless on purpose, to fix #2420: the full schema pushed the helm release Secret over 1 MiB (`Too long: may not be more than 1048576 bytes`) and broke upgrades. |
16 | | -- The full strict CRDs are available via the `crds` subchart, gated on `crds.plain: true`. That path uses a kubectl-apply Job (init container `bzcat` of `crd.yaml.bz2`) to install CRDs server-side instead of through helm-managed manifests, dodging the 1 MiB limit. |
| 9 | +- Webhook path `/validate-operator-victoriametrics-com-v1beta1-vmagent` matches upstream. |
| 10 | +- Service port 9443 → targetPort `webhook` (containerPort 9443). Plumbing is fine. |
| 11 | +- TLS cert SANs cover `<svc>.<ns>.svc` and `.svc.<cluster.dnsDomain>`. |
| 12 | +- `failurePolicy: Fail`, so an unreachable webhook would *reject*, not accept. The manifest being accepted proves the webhook is reachable and returns "allow". |
17 | 13 |
|
18 | | -## Workaround for users |
| 14 | +Trajectory: divergent against. Webhook plumbing is correct. |
19 | 15 |
|
20 | | -Set: |
| 16 | +## H₂ — operator's validator doesn't see unknown fields (CONFIRMED, deduction) |
| 17 | +**Perturbation**: read `internal/webhook/operator/v1beta1/vmagent_webhook.go@v0.70.0`. |
21 | 18 |
|
22 | | -```yaml |
23 | | -crds: |
24 | | - plain: true |
25 | | -admissionWebhooks: |
26 | | - enabled: true |
| 19 | +```go |
| 20 | +func (*VMAgentCustomValidator) ValidateCreate(_ context.Context, obj *vmv1beta1.VMAgent) { |
| 21 | + if obj.Status.ParsingSpecError != "" { return ..., errors.New(...) } |
| 22 | + if err := obj.Validate(); err != nil { return ..., err } |
| 23 | + return nil, nil |
| 24 | +} |
27 | 25 | ``` |
28 | 26 |
|
29 | | -With `crds.plain: true`, the operator chart short-circuits its specless `templates/crd.yaml` (see `{{- if and (not .Values.crds.plain) .Values.crds.enabled }}` guard) and lets the `crds` subchart deliver the full schema via an upgrade Job. Apiserver-side strict validation then rejects unknown fields like `test: true` without any webhook involvement. |
| 27 | +Validator runs on a parsed Go struct. Unknown JSON fields (`test: true`) are dropped by `json.Unmarshal` before the validator sees them. The webhook **cannot** catch unknown-field errors on the protocol level — it operates after apiserver→struct decode. |
30 | 28 |
|
31 | | -Note: the subchart's `upgrade.enabled` is also `false` by default; users likely need `crds.upgrade.enabled: true` as well for the Job to actually run. |
| 29 | +## H₃ — chart ships specless CRDs; upstream ships full schema (CONFIRMED, induction) |
| 30 | +**Perturbation**: compare CRDs. |
32 | 31 |
|
33 | | -## Hypothesis graph |
| 32 | +| Source | VMAgent CRD `spec` schema | |
| 33 | +|---|---| |
| 34 | +| `charts/victoria-metrics-operator/crd.yaml` (default, `crds.plain: false`) | `{"required":["remoteWrite"],"type":"object","x-kubernetes-preserve-unknown-fields":true}` — **specless** | |
| 35 | +| `charts/victoria-metrics-operator/charts/crds/crds/crd.yaml` (when `crds.plain: true`) | Full `properties:` schema, no preserve-unknown-fields. 45,807-line file. | |
| 36 | +| Upstream `install-with-webhook.yaml` | Full `properties:` schema. | |
34 | 37 |
|
35 | | -### H₀ — webhook config malformed (path/port/CA wrong) |
| 38 | +With `x-kubernetes-preserve-unknown-fields: true` and no `properties`, the **apiserver itself** accepts any field under `spec`. There is no schema-level rejection. |
36 | 39 |
|
37 | | -- **Perturbation:** render the chart, compare each webhook entry to upstream `install-with-webhook.yaml`. |
38 | | -- **Result:** path scheme matches (`/validate-operator-victoriametrics-com-<version>-<singular>`). Port is `9443` (explicit) vs upstream omits (defaults 443) — but the helm Service exposes port 9443 → targetPort `webhook` (=9443 in deployment). CA is self-generated and inlined; cert dir matches operator default `/tmp/k8s-webhook-server/serving-certs`. ObjectSelector excludes `app.kubernetes.io/name = victoria-metrics-operator`; a user-applied VMAgent without that label still matches `NotIn` (label-selector semantics: missing key satisfies NotIn). |
39 | | -- **Trajectory:** convergent — the webhook IS reachable. |
40 | | -- **Status:** killed. Wiring isn't the issue. |
41 | | -- **Mode:** deduction (read the rendered manifest, compared to upstream). |
| 40 | +Upstream rejects `test: true` at the **apiserver layer**, before the webhook runs, via CRD-pruning. The webhook is incidental for this failure mode. |
42 | 41 |
|
43 | | -### H₁ — operator webhook validator only checks known fields |
| 42 | +**Provenance**: helm-charts PR #2419 ("operator: make templated crds specless"), merged 2025-09-27. Closed #2420 and #2334 (rendering perf issues). Deliberate tradeoff: apiserver-side schema validation for chart-render speed. |
44 | 43 |
|
45 | | -- **Perturbation:** read `internal/webhook/operator/v1beta1/vmagent_webhook.go`. |
46 | | -- **Result:** `ValidateCreate` returns based on `obj.Status.ParsingSpecError` (not set at create) and `obj.Validate()` (typed struct validation). At decode time, unknown JSON fields not in the Go struct are silently dropped — webhook can't see `test: true`. |
47 | | -- **Trajectory:** divergent (against "webhook can catch this"). Webhook is structurally incapable of catching unknown fields. |
48 | | -- **Status:** confirmed. |
49 | | -- **Mode:** deduction. |
50 | | - |
51 | | -### H₂ — specless CRD allows unknown fields through |
52 | | - |
53 | | -- **Perturbation:** extract VMAgent CRD from helm `crd.yaml` vs `install-with-webhook.yaml`. Compare schema for `.spec.versions[].schema.openAPIV3Schema.properties.spec`. |
54 | | -- **Result:** |
55 | | - - Helm chart (templated path): 107 lines for VMAgent CRD; `spec: { type: object, x-kubernetes-preserve-unknown-fields: true }`. |
56 | | - - Upstream install-with-webhook: 3096 lines for VMAgent CRD; full property list, no preserveUnknownFields. |
57 | | -- **Trajectory:** divergent (for the diagnosis). With `preserveUnknownFields: true`, apiserver structural-schema rejection doesn't fire on unknown keys under `spec`. |
58 | | -- **Status:** confirmed root cause. |
59 | | -- **Mode:** induction (compared the actual rendered CRDs). |
60 | | - |
61 | | -### H₃ — chart provides an opt-in to the strict CRDs |
62 | | - |
63 | | -- **Perturbation:** inspect `templates/crd.yaml` and `Chart.yaml`. Trace the `crds.plain` flag. |
64 | | -- **Result:** `crds.plain: true` disables the specless templated CRDs AND enables the `crds` subchart (gated by `condition: crds.plain`), which ships `crds/crd.yaml` containing the full 3000-line schema per CRD and applies via a kubectl Job. |
65 | | -- **Trajectory:** convergent — the chart already has the right escape hatch, it's just not the default. |
66 | | -- **Status:** confirmed. |
67 | | -- **Mode:** deduction. |
| 44 | +## Diagnosis |
| 45 | +The chart's default-mode (`crds.plain: false`) ships specless CRDs that disable apiserver-side schema validation. The admission webhook validates a Go struct, which silently drops unknown JSON keys. So malformed manifests slip through. |
68 | 46 |
|
69 | | -### H₄ — default flip would regress #2420 (>1 MiB helm release Secret) |
| 47 | +`install-with-webhook.yaml` includes full-schema CRDs; apiserver rejects unknown fields before the webhook runs. |
70 | 48 |
|
71 | | -- **Perturbation:** read PR #2419 and issue #2420. |
72 | | -- **Result:** specless was a deliberate fix for the helm release Secret hitting 1 MiB. Flipping the default back to plain CRDs in `templates/crd.yaml` would resurrect that bug. |
73 | | -- **Trajectory:** divergent against "just change the default". The subchart path (kubectl Job applying CRDs out of helm's Secret) is the only safe way to ship the full schema, and it has its own UX cost (extra Job, extra ServiceAccount). |
74 | | -- **Status:** confirmed — there's no clean default flip. |
75 | | -- **Mode:** deduction (read the prior PR + issue). |
| 49 | +Setting `crds.plain: true` (or pre-applying upstream CRDs) restores rejection. Workaround already available; no chart bug in the routing sense. |
76 | 50 |
|
77 | | -## Graph state |
| 51 | +## Candidate fixes |
78 | 52 |
|
79 | | -| Node | Status | Shape | Mode | |
80 | | -|------|--------|-------|------| |
81 | | -| H₀ webhook wiring wrong | killed | convergent | deduction | |
82 | | -| H₁ webhook can't see unknown fields | confirmed | divergent | deduction | |
83 | | -| H₂ specless CRD preserves unknown fields | confirmed (root cause) | divergent | induction | |
84 | | -| H₃ `crds.plain: true` opt-in exists | confirmed | convergent | deduction | |
85 | | -| H₄ default flip would regress #2420 | confirmed | divergent | deduction | |
| 53 | +1. **Doc fix (low risk)**: clarify in `values.yaml` and README that `crds.plain: false` ships specless CRDs and that admission webhooks alone do not reject unknown fields; recommend `crds.plain: true` (or applying the upstream CRD bundle) for strict validation. |
| 54 | +2. **Default flip to `crds.plain: true`**: re-introduces the rendering perf hit PR #2419 explicitly chose to avoid. Needs maintainer signoff. |
| 55 | +3. **Specless-but-strict**: drop `x-kubernetes-preserve-unknown-fields: true` from the specless CRD. Apiserver would then reject **all** spec fields (no properties defined). Breaks valid manifests. Not viable. |
86 | 56 |
|
87 | | -## Reframe |
| 57 | +## Recommendation |
| 58 | +Halt at diagnosis. The fix is a tradeoff only the maintainer can pick. |
88 | 59 |
|
89 | | -Original framing: "admissionWebhooks aren't working". Actual finding: webhooks **are** working as designed; the cluster's structural schema validation isn't, because the templated CRDs are deliberately specless to fit under helm's 1 MiB Secret cap. The webhook layer was never the right place to catch unknown-field errors — that's the CRD schema's job. |
| 60 | +- Option (1) is a one-paragraph doc clarification. |
| 61 | +- Option (2) reverses the explicit decision in PR #2419 and needs maintainer judgment. |
| 62 | +- Option (3) is structurally impossible. |
90 | 63 |
|
91 | | -## Verdict |
| 64 | +Substrate shouldn't ship "a fix" that papers over a deliberate design decision. Route to operator for a yes/no on PRing option (1). |
92 | 65 |
|
93 | | -No fix to ship. The behavior is a documented trade-off: chart maintainers picked "small helm releases" over "strict schema by default". Users who want strict validation should set `crds.plain: true` and `crds.upgrade.enabled: true`. A README/values.yaml note clarifying that field-validation strictness depends on `crds.plain` would help future users hit this less, but that's a docs-only nit and not a behavioral bug. |
| 66 | +## Reasoning mode table |
| 67 | +| Claim | Mode | Confidence | |
| 68 | +|---|---|---| |
| 69 | +| Webhook routing is correct | deduction (rendered manifests) | 98% | |
| 70 | +| Webhook can't see unknown JSON fields | deduction (Go source) | 99% | |
| 71 | +| Chart's specless CRDs accept unknown fields; upstream's full CRDs don't | deduction (schema comparison) | 99% | |
| 72 | +| PR #2419 was a deliberate perf tradeoff | deduction (PR description) | 95% | |
94 | 73 |
|
95 | | -→ Route to tissue (report findings, no PR). |
| 74 | +## Frontier (open) |
| 75 | +- Live verify in `kind` that `crds.plain: true` restores rejection (untested; pure inference). |
| 76 | +- Confirm v0.62.1 (user's version) ships the same specless CRDs as v0.63.0 — almost certainly yes; PR #2419 merged well before v0.62.1. |
0 commit comments