Skip to content

fix: validate podspec scheduling syntax on WekaCluster/WekaClient admission (OP-361) - #2734

Open
caspx wants to merge 1 commit into
mainfrom
08-12-fix_validate_podspec_scheduling_syntax_on_wekacluster_wekaclient_admission_op-361_
Open

fix: validate podspec scheduling syntax on WekaCluster/WekaClient admission (OP-361)#2734
caspx wants to merge 1 commit into
mainfrom
08-12-fix_validate_podspec_scheduling_syntax_on_wekacluster_wekaclient_admission_op-361_

Conversation

@caspx

@caspx caspx commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Adds admission validators that reject WekaCluster/WekaClient specs whose scheduling-related fields would produce pods the API server rejects or that can never schedule — caught at CR apply instead of at pod create.

What changed?

  • New cluster_podspec_syntax / client_podspec_syntax validators (default Error in both strict and relaxed postures), sharing internal/validation/podspec_syntax.go — syntax rules mirrored from upstream k8s v1.33 pkg/apis/core/validation for tolerations, nodeSelector, label/annotation maps, node/pod affinity, and topologySpreadConstraints; the cluster validator also gates failureDomain (label / skew / compositeLabels, mirroring factory precedence).
  • Registered in the validation registry + admission defaults; documented in values.yaml and doc/operator/operations/admission-control.md.
  • Release note: pre-existing CRs with invalid scheduling fields are grandfathered until their next user update, which will then be rejected with the validator's message.

How to test?

  • go test ./internal/validation/... ./internal/admission/...
  • Live: kubectl apply --dry-run=server a WekaClient with the ticket's toleration (scitix.ai/nodecheck:NoSchedule as a raw string) → denied with a rawTolerations hint; valid baseline specs admit.

Why make this change?

OP-361: a WekaClient with a malformed toleration was admitted, then every generated WekaContainer pod failed to start — and deleting the containers didn't help, since they were recreated with the same bad toleration. The webhook now rejects the spec up front.

caspx commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

How to use the Graphite Merge Queue

Add the label main-merge-queue to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has required the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails
npm/undici 6.28.0 🟢 7.7
Details
CheckScoreReason
Code-Review🟢 10all changesets reviewed
Dependency-Update-Tool🟢 10update tool detected
Maintained🟢 1030 commit(s) and 7 issue activity found in the last 90 days -- score normalized to 10
Security-Policy🟢 10security policy file detected
Binary-Artifacts🟢 8binaries present in source code
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Pinned-Dependencies🟢 6dependency not pinned by hash detected -- score normalized to 6
Signed-Releases⚠️ -1no releases found
Vulnerabilities⚠️ 011 existing vulnerabilities detected
SAST🟢 10SAST tool is run on all commits
Fuzzing🟢 10project is fuzzed
Packaging🟢 10packaging workflow detected
Branch-Protection⚠️ -1internal error: error during branchesHandler.setup: internal error: some github tokens can't read classic branch protection rules: https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
License🟢 10license file detected
CI-Tests🟢 1030 out of 30 merged PRs checked by a CI test -- score normalized to 10
Contributors🟢 10project has 63 contributing companies or organizations
npm/undici 7.29.0 🟢 7.7
Details
CheckScoreReason
Code-Review🟢 10all changesets reviewed
Dependency-Update-Tool🟢 10update tool detected
Maintained🟢 1030 commit(s) and 7 issue activity found in the last 90 days -- score normalized to 10
Security-Policy🟢 10security policy file detected
Binary-Artifacts🟢 8binaries present in source code
Token-Permissions⚠️ 0detected GitHub workflow tokens with excessive permissions
Dangerous-Workflow🟢 10no dangerous workflow patterns detected
CII-Best-Practices⚠️ 0no effort to earn an OpenSSF best practices badge detected
Pinned-Dependencies🟢 6dependency not pinned by hash detected -- score normalized to 6
Signed-Releases⚠️ -1no releases found
Vulnerabilities⚠️ 011 existing vulnerabilities detected
SAST🟢 10SAST tool is run on all commits
Fuzzing🟢 10project is fuzzed
Packaging🟢 10packaging workflow detected
Branch-Protection⚠️ -1internal error: error during branchesHandler.setup: internal error: some github tokens can't read classic branch protection rules: https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
License🟢 10license file detected
CI-Tests🟢 1030 out of 30 merged PRs checked by a CI test -- score normalized to 10
Contributors🟢 10project has 63 contributing companies or organizations

Scanned Files

  • package-lock.json

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Reviewing PR

  • Gather context (read diff + related code)
  • Review validation logic for bugs/correctness
  • Check integration (registry, defaults, values.yaml)
  • Post review

View job run

@caspx
caspx marked this pull request as ready for review August 12, 2026 11:38
@caspx
caspx requested a review from a team as a code owner August 12, 2026 11:38
Copilot AI lite review requested due to automatic review settings August 12, 2026 11:38
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @caspx's task in 6m 4s —— View job


Review

  • Gather context (read diff + related code)
  • Review validation logic for bugs/correctness
  • Check integration (registry, defaults, values.yaml)
  • Post review

Solid, careful PR. The upstream mirroring is accurate where I could check it against k8s semantics (toleration key/operator/value/effect interaction, node-affinity value-count rules per operator, label values validated in required node-affinity terms only, weight 1-100, topologyKey required only non-empty for spread constraints but qualified-name for pod affinity). Field paths in errors are precise and the tests pin them, which is what makes a webhook message actually actionable. The "what is intentionally not checked" comments (namespaces, matchLabelKeys, minDomains, node inclusion policies) are the right way to document a partial mirror.

Two things I verified beyond the diff, both good news:

  • Grandfathering actually works: WekaClusterCustomValidator.ValidateUpdate short-circuits on reflect.DeepEqual(oldSpec, newSpec) (internal/admission/wekacluster.go:57), so untouched bad CRs keep reconciling.
  • No risk of wedging the operator: the operator's own writes to these CRs are Status().Update or metadata-only annotation patches (funcs_prepull.go:161,215, client_reconciler_loop.go:1680), which the DeepEqual short-circuit lets through.
  • failureDomain precedence matches the factory (container_factory.go:220-240) — including only validating skew under label and skipping dead fields. The tests for dead fields (cluster_podspec_syntax_test.go:158-169) are the good kind of test.

Findings (4 inline comments)

  1. Hand-maintained role tables are a silent coverage hole (cluster_podspec_syntax.go:35-133) — a new role field in RoleNodeSelector/RoleAffinity/etc. gets skipped, and the tests enumerate the same hardcoded list so nothing fails. Suggested a reflect.NumField() assertion in the test. Fix this →
  2. Denial message echoes the whole raw JSON blob (podspec_syntax.go:229 and :268) — string(raw.Raw) for a multi-KB affinity makes the kubectl apply error hard to read; the json error already localizes the problem. Suggested truncation. Fix this →
  3. Doc/code disagreement on failureDomain precedence (cluster_podspec_syntax.go:69-92) — doc/api_dump/wekacluster.md:185 says compositeLabels wins over label; the code says the opposite. The validator correctly follows the code, but that leaves a documented-but-wrong path where compositeLabels typos stay invisible. Fix belongs in the weka-k8s-api field comment.
  4. Relaxed: Error + grandfathering interaction (defaults.go:20,25) — the first unrelated edit to a pre-existing bad CR is denied with an error about a field the user didn't touch in that apply. Reasonable as-is, but relaxed-posture fleets get this as an upgrade surprise; consider Relaxed: Warn for one release, or call out the per-policy override in the release note.

Smaller notes (no inline comment)

  • validateSimpleTolerations treats "" as a valid tolerate-all entry (podspec_syntax.go:28-31). That relies on ExpandTolerations semantics, and the test asserts the validator's behaviour, not the expansion — so if the assumption is wrong, nothing catches it. Low impact (empty entries are unlikely), but worth a glance at the expansion code.
  • Coverage gap: WekaContainer has the same scheduling fields and is registered for admission (update-only), so a hand-applied WekaContainer still reproduces the OP-361 failure mode. The shared helpers make a container_podspec_syntax validator nearly free — though it would then also run against operator-generated containers, which is a trade-off (catches operator bugs / can block reconciliation) worth deciding deliberately rather than by omission.
  • validateLabelMap is the right rule for the CSI nodeLabels/controllerLabels — I checked, they land as object labels via GetCsiLabels (csi/daemonset.go:65, csi/controller.go:121), not as selectors of a different syntax.
  • Nil-safety is fine: RoleNodeSelector/RoleAnnotations are value structs and the pointer parents (PodConfig, CsiConfig.Advanced) are all guarded.

Not verified

I could not compile or run the tests in this checkout: go.mod replaces github.com/weka/weka-k8s-api with ./pkg/weka-k8s-api, and that submodule directory is empty here, so go build ./internal/validation/ cannot resolve the API types. Nothing in my review depends on running them, but the go test ./internal/validation/... ./internal/admission/... result from CI is the authority on the test cases. I also had no network access to diff the mirrored rules against the actual upstream v1.33 source — my assessment of the mirroring is from knowledge of those validators, not a line-by-line comparison.
branch

@graphite-app

graphite-app Bot commented Aug 12, 2026

Copy link
Copy Markdown

Graphite Automations

"Add anton/matt/sergey/kristina as reviwers on operator PRs" took an action on this PR • (08/12/26)

3 reviewers were added to this PR based on Anton Bykov's automation.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds new admission-time validators for WekaCluster and WekaClient to reject invalid scheduling-related spec fields (tolerations, selectors, affinity, topology spread constraints, failureDomain) earlier—at CR admission instead of later at Pod creation.

Changes:

  • Introduces shared pod-spec syntax validation helpers plus new cluster_podspec_syntax and client_podspec_syntax validators, and registers them in the validation registry.
  • Wires the new policies into admission defaults and documents the new policy IDs in Helm values + operator docs (and updates .ainav navigation).
  • Updates package-lock.json (undici version bumps in two dependency locations).

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
package-lock.json Bumps undici dependency versions.
internal/validation/registry.go Registers the new cluster/client podspec syntax validators.
internal/validation/podspec_syntax.go Adds shared scheduling/podspec syntax validation helpers.
internal/validation/podspec_syntax_test.go Unit tests for the shared podspec syntax helpers.
internal/validation/cluster_podspec_syntax.go Implements cluster_podspec_syntax admission validator.
internal/validation/cluster_podspec_syntax_test.go Tests for cluster podspec syntax validation behavior and role wiring.
internal/validation/client_podspec_syntax.go Implements client_podspec_syntax admission validator (incl. CSI advanced fields).
internal/validation/client_podspec_syntax_test.go Tests for client podspec syntax validation behavior.
internal/admission/defaults.go Adds default severities for the new policies.
doc/operator/operations/admission-control.md Updates admission-control documentation scope/behavior.
charts/weka-operator/values.yaml Documents new policy IDs and their defaults in Helm values comments.
.ainav/config/index.md Updates navigation docs to include new podspec syntax validators.
Suppressed comments (1)

internal/validation/podspec_syntax.go:269

  • Returning the full raw JSON as the "bad value" can bloat admission errors (and potentially exceed response limits). Consider using a short placeholder for the bad value and keep details in the message.
	if err := json.Unmarshal(raw.Raw, &constraints); err != nil {
		return field.ErrorList{field.Invalid(fldPath, string(raw.Raw), fmt.Sprintf("does not unmarshal into []v1.TopologySpreadConstraint: %v", err))}
	}

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +8 to +13
import (
"encoding/json"
"fmt"
"strings"

corev1 "k8s.io/api/core/v1"
Comment on lines +5 to +8
The operator runs a validating admission webhook for `WekaCluster`, `WekaClient`, and
`WekaContainer` resources. On every `kubectl apply` (or Helm/GitOps equivalent) it runs a battery
of policies and either admits the request, attaches a `kubectl Warning:` line, or
rejects it. The default posture is non-blocking — most policies emit warnings;
rejects it (`WekaContainer` carries update-only policies, e.g. cores decrease).
Comment on lines +137 to +140
case corev1.NodeSelectorOpGt, corev1.NodeSelectorOpLt:
if len(expr.Values) != 1 {
errs = append(errs, field.Required(ep.Child("values"), "must be a single value when operator is Gt or Lt"))
}
Comment on lines +228 to +230
if err := json.Unmarshal(raw.Raw, &aff); err != nil {
return field.ErrorList{field.Invalid(fldPath, string(raw.Raw), fmt.Sprintf("does not unmarshal into a v1.Affinity: %v", err))}
}
Comment on lines +35 to +45
roleNodeSelectors := []struct {
role string
sel *map[string]string
}{
{"compute", wc.Spec.RoleNodeSelector.Compute},
{"drive", wc.Spec.RoleNodeSelector.Drive},
{"s3", wc.Spec.RoleNodeSelector.S3},
{"nfs", wc.Spec.RoleNodeSelector.Nfs},
{"smbw", wc.Spec.RoleNodeSelector.Smbw},
{"dataServices", wc.Spec.RoleNodeSelector.DataServices},
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The four role tables here (and the mirrored role lists in cluster_podspec_syntax_test.go:178-179) are hand-maintained. If someone adds an envoy/dataServices field to RoleNodeSelector / RoleAffinity / RoleTopologySpreadConstraints in weka-k8s-api, that field is silently unvalidated and no test fails — the tests enumerate the same hardcoded list, so they agree with the bug.

Cheap guard: a reflection assertion in the test, e.g.

if got := reflect.TypeOf(weka.RoleNodeSelector{}).NumField(); got != len(sixRoles) {
    t.Fatalf("RoleNodeSelector has %d fields, table covers %d — update the validator", got, len(sixRoles))
}

(one per struct). That turns a silent coverage hole into a compile-time-ish failure at the next API bump.

}
var aff corev1.Affinity
if err := json.Unmarshal(raw.Raw, &aff); err != nil {
return field.ErrorList{field.Invalid(fldPath, string(raw.Raw), fmt.Sprintf("does not unmarshal into a v1.Affinity: %v", err))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

string(raw.Raw) embeds the entire affinity blob into the denial message (same at line 268 for topology spread constraints). A realistic podConfig.affinity is a few KB of JSON, and this ends up in the kubectl apply error the user sees, buried around the actual json: parse error.

Consider truncating the echoed value, e.g. a small helper:

func truncateRaw(b []byte) string {
    const max = 256
    if len(b) > max {
        return string(b[:max]) + "…"
    }
    return string(b)
}

The err from json.Unmarshal already names the offending field/offset, so the full blob adds little.

Comment on lines +69 to +92
if fd := wc.Spec.FailureDomain; fd != nil {
// mirror getDefaultRoleTopologySpreadConstraints precedence: label
// wins over compositeLabels; skew is used only with label
if fd.Label != nil {
if *fd.Label == "" {
errs = append(errs, field.Required(spec.Child("failureDomain", "label"), "failureDomain label may not be empty when set"))
} else {
errs = append(errs, validateTopologyKey(spec.Child("failureDomain", "label"), *fd.Label)...)
}
// skew becomes the generated spread constraint's maxSkew (must be > 0)
if fd.Skew != nil && *fd.Skew <= 0 {
errs = append(errs, field.Invalid(spec.Child("failureDomain", "skew"), *fd.Skew, "must be greater than zero"))
}
} else {
for i, l := range fd.CompositeLabels {
p := spec.Child("failureDomain", "compositeLabels").Index(i)
if l == "" {
errs = append(errs, field.Required(p, "failureDomain compositeLabels entries may not be empty"))
} else {
errs = append(errs, validateTopologyKey(p, l)...)
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The precedence mirrored here matches the code (container_factory.go:220-240: Label != nil wins, CompositeLabels only in the else), which is the right thing to mirror. But the published API doc says the opposite — doc/api_dump/wekacluster.md:185: "If compositeLabels is set, label and skew will be ignored."

Consequence: a user who follows the doc and sets both gets their compositeLabels neither used nor validated, and a typo there stays invisible. Worth fixing the field comment in weka-k8s-api (source of the generated dump) as a follow-up so doc and validator agree — otherwise this validator quietly cements the undocumented precedence.


wekaClientDefaults = map[string]PolicyDefaults{
"client_target_cluster_exists": {Strict: Error, Relaxed: Warn},
"client_podspec_syntax": {Strict: Error, Relaxed: Error},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relaxed: Error here (and line 20) is defensible — a syntactically invalid podspec can never schedule, so warning about it is pointless. But note the interaction with the grandfathering behaviour: ValidateUpdate short-circuits only while spec is byte-identical (wekacluster.go:57), so the first unrelated edit to an existing bad CR (image bump, core count change) gets denied with a toleration/nodeSelector error the user didn't touch in that apply.

For a relaxed-posture fleet that's a rollout surprise on operator upgrade. Two mitigations worth considering: ship Relaxed: Warn for one release and flip to Error after, or make sure the release note explicitly tells relaxed-posture operators to pre-scan existing CRs (the per-policy override in admissionPolicies is the escape hatch either way — maybe mention it in the note).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants