Skip to content

feat(charts): add nodeSelector / tolerations / affinity support to workload charts - #321

Merged
PiyushSingh-ZS merged 3 commits into
mainfrom
fix/issue-320
Sep 11, 2026
Merged

PiyushSingh-ZS merged 3 commits into
mainfrom
fix/issue-320

Conversation

@PiyushSingh-ZS

Copy link
Copy Markdown
Collaborator

Closes #320

Description

None of the workload charts exposed pod-scheduling controls, so a chart-managed workload could not be placed on a dedicated / tainted node pool through Helm. Pods that had to land on such a pool were pinned out-of-band (kubectl patch), which a helm upgrade silently erases by re-rendering the pod template without the scheduling block.

This wires nodeSelector, tolerations and affinity into every Deployment and StatefulSet pod spec — 24 charts, 34 workload templates — with empty defaults in each values.yaml, using the usual guarded blocks so an unset value renders nothing.

Charts covered: cassandra, chromadb, clickhouse, cockroachdb, dgraph (zero + alpha), holmesgpt, kafka, litellm, localai (standalone + frontend + worker), mariadb (master + slave), mosquitto, mysql, opentsdb, postgres (primary + slaves), qdrant, redis, redisdistributed (master + slave), scylladb, service, solr, superset (node/worker/beat/flower), surrealdb, uptime-monitoring (prometheus + blackbox), zookeeper.

Two fixes needed for affinity to work at all

  1. kafka and zookeeper had a broken .Values.affinity. Both already read it, but rendered it as {{ toYaml ... | indent 8 }} on a line that was already indented 8 spaces — double-indenting the block. Setting affinity produced invalid YAML and helm template failed outright. Switched to {{- ... | nindent 8 }}.

  2. cockroachdb, dgraph and mariadb hardcoded a podAntiAffinity with no way to override it. Wrapped it in the same if/else pattern kafka/zookeeper use, so affinity replaces it. Those five READMEs call out that setting affinity replaces rather than merges with the chart's default anti-affinity.

Other notes

  • uptime-monitoring is the only chart with additionalProperties: false, so its values.schema.json needed the three properties added or installs failed schema validation.
  • Subcharts are scheduled separately. kafka→zookeeper, litellm→postgres, localai→postgres/nats, opentsdb→zookeeper and superset→postgres/redis pull pinned published dependency versions, so those pods stay unpinnable until the dependency versions are bumped to the ones released here. Documented in each README — flagging it as the one part of the issue not fully closed by this PR alone.
  • mosquitto was never published at v0.0.1, so it is released at v0.0.1 rather than bumped to v0.0.2.
  • Skipped: jupyterhub, karpenter-gcp, solr-operator, zookeeper-operator (vendored upstream charts); openobserve-standalone already had all three keys.

Type of Change

  • New feature (backward compatible)
  • Bug fix (kafka / zookeeper affinity rendered invalid YAML)
  • Chart configuration update
  • Documentation update

Verification

No real cluster was used — everything below is template-level.

  • helm lint — 24/24 charts clean.
  • No-op proof — rendered all 24 charts at defaults against an origin/main worktree. Output is byte-identical apart from randomly generated passwords. None of the three keys appears anywhere with defaults, so existing releases are unaffected.
  • Positive proof — a script parses the rendered YAML for all 34 templates and asserts each pod spec's nodeSelector / tolerations / affinity deep-equal the supplied values, enabling replication.enabled, distributed.enabled and supersetCeleryBeat/Flower to reach the conditional workloads. Result: 34 ok, 0 failed.
  • Falsification check — reverting one chart's template makes the script report MISMATCH ... nodeSelector=None, confirming it pins the boundary rather than passing vacuously.
  • Packaging — 24 new docs/*.tgz plus a regenerated docs/index.yaml. Verified 0 previously-published versions lost and no digest or URL drift on existing entries; the only change to old entries is the created timestamp, inherent to helm repo index regeneration. Packaged tarballs were re-rendered to confirm they aren't stale.

Checklist

  • Code and chart conform to Helm best practices
  • helm lint passes without errors
  • All changes are properly documented (a "Pod Scheduling" section in all 24 READMEs)
  • Charts repackaged into docs/ and docs/index.yaml regenerated
  • Backward compatible — empty defaults render nothing

…harts

Wires the three standard pod-scheduling knobs into every Deployment and
StatefulSet pod spec across 24 charts (34 workload templates), with empty
defaults in values.yaml so unset renders nothing and existing releases are
unaffected. This makes dedicated / tainted node-pool placement expressible
through Helm instead of an out-of-band kubectl patch that a helm upgrade
silently erases.

Two fixes were needed for affinity to work at all:

- kafka and zookeeper already read .Values.affinity but rendered it as
  `{{ toYaml ... | indent 8 }}` on an already-indented line, double-indenting
  the block. Setting affinity produced invalid YAML and helm template failed.
  Switched to nindent.
- cockroachdb, dgraph and mariadb hardcoded a podAntiAffinity with no
  override. Wrapped it in the same if/else kafka and zookeeper use, so
  affinity replaces it. Their READMEs note the replace-not-merge behaviour.

uptime-monitoring is the only chart with additionalProperties: false, so its
values.schema.json needed the three properties or installs failed validation.

Subcharts are scheduled separately: kafka->zookeeper, litellm->postgres,
localai->postgres/nats, opentsdb->zookeeper and superset->postgres/redis pull
pinned published versions, so their pods stay unpinnable until those
dependency versions are bumped. Documented in each README.

mosquitto was never published at v0.0.1, so it is released at v0.0.1 rather
than bumped to v0.0.2.

Verified with helm lint (24/24), a defaults render diffed against main
(byte-identical apart from generated passwords), and a structural check that
all 34 pod specs deep-equal the supplied scheduling values.

@arunesh-j arunesh-j left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — PR #321, nodeSelector / tolerations / affinity across 24 workload charts

Reviewed on a worktree of review/pr-321 against origin/main.

What I verified, and what I only read. Verified by running: helm dependency update + helm lint
on all 24 charts (24/24 clean, 0 failures); rendering all 24 at defaults and with all three keys set,
parsing the YAML and asserting each pod spec; verify-index.py against origin/main; every subchart
pass-through claim below; and a cluster test on local minikube — cockroachdb and postgres
installed onto a node labelled workload=stateful and tainted workload=stateful:NoSchedule, which
is the exact configuration the feature is for. The first finding comes from that install, not from reading.

The change is well built. Defaults render nothing, so existing releases are untouched; the
kafka / zookeeper indent 8 → nindent 8 fix is real (the old form double-indented and made
affinity unrenderable); the if/else wrapping of the hardcoded podAntiAffinity in cockroachdb,
dgraph and mariadb is applied consistently, and the "setting affinity replaces the default
anti-affinity" caveat appears in exactly those five READMEs and nowhere else — which is correct.
docs/index.yaml was genuinely regenerated (0/183 created: timestamps carried over), 24 added,
0 removed, 0 changed. No committed subchart tarballs, no tabs, branch name compliant.

Nothing fails CI. But the first finding below produces a release that reports deployed and is silently
broken, so I'd take that one before merge; the rest are about the feature's reach rather than its
correctness.


1. [Should fix — verified on a cluster] A chart's own init Jobs were skipped, and the release then deploys silently broken

  • charts/cockroachdb/templates/job.init.yaml
  • charts/postgres/templates/init-script-config-map.yaml (the db-init Job)
  • charts/scylladb/templates/user-job.yaml
  • charts/superset/templates/init-job.yaml

The StatefulSets in these charts take the scheduling block; the Jobs sitting beside them in the same
chart do not. That is the whole bug: a toleration exists precisely so a pod can land on a tainted
pool, so a workload where some of the pods get the toleration is worse than one where none do — the
release comes up looking healthy and is not.

Installed on minikube with the node labelled workload=stateful and tainted
workload=stateful:NoSchedule, values setting nodeSelector + tolerations to match.

cockroachdb — the StatefulSet honours both keys and schedules; the init Job cannot:

r321-cockroachdb-0            0/1  Running   0 restarts   5m57s
r321-cockroachdb-1            0/1  Running   0 restarts   5m57s
r321-cockroachdb-2            0/1  Running   0 restarts   5m57s
r321-cockroachdb-init-jpd8h   0/1  Pending   0 restarts   5m57s

FailedScheduling: 0/1 nodes are available: 1 node(s) had untolerated taint {workload: stateful}
Unhealthy (x62): Readiness probe failed: HTTP probe failed with statuscode: 503

helm install reported STATUS: deployed. Six minutes later no pod had ever become Ready and none
ever would: cockroach init lives in that Job, so the cluster is never initialised. Zero restarts —
nothing crash-loops, nothing logs an error, the release just never works.

postgres — same shape, and the damage is quieter:

pgt-postgres-0                         2/2  Running   0 restarts
postgres-pgt-app-init-02b8899b-vsx6s   0/1  Pending   0 restarts

$ psql -U postgres -tAc "SELECT datname FROM pg_database"
postgres  template0  template1        # 'appdb' was never created

Postgres reports 2/2 Running, helm reports deployed, and the database the Job exists to create is
simply absent. Whatever consumes it fails later, far from the cause.

One correction to what the PR body implies about --wait: plain --wait does not cover Jobs, which
is why the install above returned success. It is --wait-for-jobs that would block — and the comment
in init-script-config-map.yaml:80 anticipates exactly that ("--wait-for-jobs would otherwise sit
on it"), where the Job would now hang to its activeDeadlineSeconds: 1800.

Extending the same {{- with }} block to those four Job pod templates closes it. The PR body
enumerates the subchart gap but not this one.

2. [Should fix] 22 of 24 charts add three top-level values keys with no values.schema.json entry

charts/<name>/values.schema.json — every touched chart except uptime-monitoring (and zookeeper,
which has no schema at all).

values.yaml gains nodeSelector, tolerations and affinity, but no schema property describes
them. In this repo the schema is not only validation — it drives the zop.dev configuration form, so a
top-level key with no property is both unvalidated and invisible in the UI. The result is that
the feature this PR exists to deliver is reachable only through -f values.yaml, not through the
product surface where most users would set it.

The tell that this was an omission rather than a decision: uptime-monitoring did get the three
properties — because it is the one chart with additionalProperties: false at the root, so skipping
them would have failed schema validation. The lint gate caught the one chart it could see.

Fix — add to each schema, following charts/litellm/values.schema.json (storeModelInDB) for style:

"nodeSelector": {
  "category": "advanced",
  "type": "object",
  "description": "Node labels the pod must match, for pinning the workload to a node pool",
  "default": {},
  "mutable": true
},
"tolerations": { "category": "advanced", "type": "array",  "description": "...", "default": [], "mutable": true },
"affinity":    { "category": "advanced", "type": "object", "description": "...", "default": {}, "mutable": true }

advanced is the right category here (it is where postgres / externalDatabase subchart blocks
live), and it is one of the four legal values — no new category is needed.

3. [Should fix] The three properties that were added omit description, default and mutable

charts/uptime-monitoring/values.schema.json:66-80

"nodeSelector": { "category": "advanced", "type": "object" },

Every sibling property in that same file carries description and default; 28 of the repo's 29
schemas use mutable. verify-values.py flags it:

[should]   3 leaf field(s) without description: nodeSelector, tolerations, affinity

Without mutable: true, CONTRIBUTING's "user-editable fields marked mutable" is unmet and the field
may render read-only in the config form — the same outcome as finding 2, by a different route.

(The [BLOCKING] root has "additionalProperties": false that the same script reports on this chart is
pre-existing on main, not yours.)

4. [Should fix] The README's subchart advice tells the reader to do something that silently does nothing

charts/kafka/README.md, charts/opentsdb/README.md, charts/superset/README.md,
charts/localai/README.md, charts/litellm/README.md — the Pod Scheduling section:

Bundled subcharts (zookeeper) are scheduled separately — set the same keys under their own value
prefix if they should follow the parent.

The second half is not true at the pinned dependency versions. Every one of those subcharts is pinned
to a published version that predates this feature, so the values are accepted and ignored:

$ helm template t charts/kafka --set zookeeper.nodeSelector.workload=stateful
  t-zookeeper   nodeSelector = None      # zookeeper pinned 0.0.1; support lands in v0.0.3, released by this PR

Same result, verified individually, for litellm→postgres 0.0.14, superset→postgres 0.0.12 and
redis 0.0.1, opentsdb→zookeeper v0.0.2, and localai→nats 2.14.4 (upstream nats uses a different
values path, so the advice misses there too — all report nodeSelector = None).

The PR description states this correctly — "those pods stay unpinnable until the dependency versions
are bumped". The READMEs say the opposite to the person actually reading them, and a user following
that line gets a silent no-op with no error to explain it. Reword to say the subchart pods cannot
currently be pinned, and name the version the dependency must reach.

5. [Note] mosquitto is published for the first time here, and it is missing its listing metadata

docs/mosquitto-v0.0.1.tgz is an add — mosquitto does not appear in origin/main's
docs/index.yaml. So this PR, whose subject is scheduling, is also mosquitto's first release. That
is fine in itself (no live install can break; the version correctly stays 0.0.1), but the chart
arrives on helm.zop.dev without the metadata that makes it visible:

  • charts/mosquitto/Chart.yaml has no icon: — it will render logo-less
  • it has no annotations.type: application|datasource — it lands in neither zop.dev listing
  • it is absent from the Available Charts table in the root README.md

None of this is caused by your change, and only the last is an unwritten convention (CONTRIBUTING's
six steps stop at the index). But this PR is what makes the chart public, so it is the moment to
either add the three things or hold mosquitto's tarball back for its own PR.


Also checked and clean: no duplicate keys in any rendered pod spec; outline / wordpress pin their
service and mysql dependencies, so the service v0.0.33 bump does not cascade into them; all 24
READMEs document the new values; all 24 values.yaml carry all three keys; no hard tabs; no
committed subchart tarballs. The Chromadb-v0.0.1.tgz URL-casing warning from verify-index.py is
pre-existing on main.

On the cluster test I also confirmed the feature does what it claims where it is wired: the
cockroachdb and postgres StatefulSet pods carried exactly the supplied nodeSelector and
tolerations and scheduled onto the tainted node, which they could not have done before this PR.

1. Init Jobs took no scheduling block. The StatefulSets in cockroachdb,
   postgres, scylladb and superset honoured nodeSelector/tolerations while
   the Jobs beside them did not, so on a tainted pool the Job stayed Pending
   and the release reported `deployed` while never working: cockroachdb never
   ran `cockroach init`, postgres never created the database. Extended the
   same guarded block to all four Job pod templates. The postgres Job sits
   inside a range over .Values.services, so it reads $.Values.

2. 22 charts gained the three top-level values keys with no schema property,
   leaving them unvalidated and invisible in the zop.dev config form. Added
   nodeSelector/tolerations/affinity to every touched values.schema.json
   under category "advanced" (zookeeper has no schema and is unchanged).

3. The three properties already added to uptime-monitoring were bare stubs.
   All 23 now carry description, default and mutable: true, matching the
   style of their sibling properties.

4. The README subchart advice told the reader to set the keys under the
   subchart prefix, which is a silent no-op at the pinned dependency
   versions. Reworded to say those pods cannot currently be pinned, naming
   the version each dependency must reach.

5. mosquitto is first published by this PR, so it gets the listing metadata
   it was missing: annotations.type, an icon, and a row in the root README.
   The icon is the generic zop.dev logo, as scylladb uses — a real logo
   should replace it.

Charts repackaged and docs/index.yaml regenerated. Verified: helm lint 24/24;
38/38 pod specs (34 workloads + 4 Jobs) deep-equal the supplied scheduling
values; defaults still render no scheduling block and diff clean against main
apart from chart-version labels; 0 index entries lost, no digest drift.
@PiyushSingh-ZS

Copy link
Copy Markdown
Collaborator Author

Thanks @arunesh-j — the cluster test caught something template-level checks could not have, and finding 1 was a real bug. All five addressed in 35ce411.

1. Init Jobs — fixed

You're right that this is worse than not supporting scheduling at all: a partially-tolerating release reports deployed and is silently broken. Extended the same guarded block to all four Job pod templates:

  • charts/cockroachdb/templates/job.init.yaml
  • charts/postgres/templates/init-script-config-map.yaml
  • charts/scylladb/templates/user-job.yaml
  • charts/superset/templates/init-job.yaml

The postgres Job sits inside a range over .Values.services, so it reads $.Values rather than .Values — worth flagging since it's the one that would have silently rendered nothing if copied verbatim.

I swept for others: kind: Job / CronJob / apiVersion: batch/v1 across all charts returns exactly your four within the 24 in scope (the rest are cron-job, solrcloud and zookeeper-operator, all outside this PR).

I also extended the verifier to Jobs and CronJobs. It now asserts 38 pod specs (34 workloads + 4 Jobs) deep-equal the supplied values — 38/38 pass, and reverting any one template makes it report MISMATCH ... nodeSelector=None.

Taking the correction on --wait vs --wait-for-jobs — my PR body was loose there.

2 & 3. Schema entries — fixed

Added nodeSelector / tolerations / affinity to all 23 touched values.schema.json files (zookeeper has none and is untouched), each with category: advanced, description, default and mutable: true. The three uptime-monitoring stubs were rewritten to the same shape.

You diagnosed my reasoning correctly: I'd taken openobserve-standalone — which carries the values but no schema entries — as evidence that schemas were deliberately partial, and added them to uptime-monitoring only because its additionalProperties: false forced my hand. That inverted cause and effect.

For the affinity description I used a variant on the five charts with a built-in podAntiAffinity, so the replace-not-merge caveat is visible in the config form and not only in the README.

4. README subchart advice — fixed

Reworded in all five to say those pods cannot currently be pinned and to name the target version, e.g. kafka:

The bundled zookeeper subchart is pinned to 0.0.1, which predates this feature, so its pods cannot currently be pinned — setting zookeeper.nodeSelector is accepted and silently ignored. Bumping the zookeeper dependency to v0.0.3 or later is what makes it work.

Same for litellm→postgres v0.0.15, superset→postgres v0.0.15 / redis v0.0.6, opentsdb→zookeeper v0.0.3. For localai I also noted that nats is upstream and uses its own values paths, so the advice never applied there.

I've also added a line to the cockroachdb / postgres / scylladb / superset READMEs noting the init Job is covered, with the failure mode from finding 1.

5. mosquitto — metadata added

Added annotations.type: datasource, an icon:, and a row in the root README's DATASOURCES table (no ✅ — the chart ships no metrics templates). Version correctly stays 0.0.1.

One thing needs your call: the icon is https://zop.dev/logo.png, the generic fallback scylladb uses, because chart logos live in a GCS bucket I can't upload to. Happy to swap in a real Mosquitto logo if you point me at an uploaded URL, or to pull docs/mosquitto-v0.0.1.tgz out of this PR entirely so it ships in its own.

Re-verification

Charts repackaged and docs/index.yaml regenerated.

  • helm lint 24/24 clean; all 24 render at defaults and with all three keys set.
  • 38/38 pod specs verified, falsification check still fails on revert.
  • Defaults re-diffed against origin/main: no structural change — only helm.sh/chart version labels and generated credentials.
  • Index: 0 entries lost, 24 added, no digest or URL drift; only created: timestamps differ.
  • Packaged tarballs re-rendered from docs/*.tgz to confirm they carry the Job fix and the schema properties, rather than being stale.

Removes the icon, annotations.type and root-README row. mosquitto keeps the
scheduling change that is this PR's subject, and is still packaged at v0.0.1.

Repackaged and reindexed so docs/mosquitto-v0.0.1.tgz matches source again.

@arunesh-j arunesh-j left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving — findings 1–4 are fixed, and I re-verified each on 7d5a721 rather than taking the reply
for it.

Finding 1, re-tested on the same cluster scenario that failed before (node labelled
workload=stateful, tainted workload=stateful:NoSchedule, all three keys set):

r321r2-cockroachdb-0            1/1  Running     0 restarts   20s
r321r2-cockroachdb-1            1/1  Running     0 restarts   20s
r321r2-cockroachdb-2            1/1  Running     0 restarts   20s
r321r2-cockroachdb-init-z8rpb   0/1  Completed   0 restarts   20s

Ready in 20 seconds, against three pods that never became Ready at all last time. postgres with
--wait --wait-for-jobs — the flag that would actually have hung on the old code — completes, and
appdb is now present where it was absent:

job.batch/postgres-pgt3-app-init-9f90b0d2   Complete   1/1   24s

$ psql -U postgres -tAc "SELECT datname FROM pg_database"
appdb  postgres  template0  template1

The Job pod carries all three keys including affinity, and $.Values inside the range renders
correctly — that was the subtle one. My own sweep for kind: Job|CronJob across the 24 charts in
scope returns exactly your four templates, all four fixed.

Findings 2 & 3: all 69 new schema properties checked mechanically — category: advanced on every
one (no fifth category), each with type, description, default and mutable: true, defaults
matching the shipped values. The affinity caveat variant is on exactly the four anti-affinity charts
that have a schema. The verify-values.py blocking items on postgres/service/cockroachdb/
uptime-monitoring are pre-existing — identical counts on origin/main, none introduced here.

Finding 4: all five READMEs now say the subchart pods cannot currently be pinned and name the
target version; the old wording is gone from the tree entirely.

Also re-verified: helm lint 24/24 clean; 32/32 own-chart pod specs carry all three keys; docs/index.yaml
regenerated with 24 added, 0 removed, 0 changed; and all 24 published tarballs byte-match their
source templates/, values.yaml and values.schema.json — the fixes are in the .tgz files, not
just the tree.


Two things to pick up, neither blocking:

  • charts/scylladb/README.md is the one of the four that did not get the init-Job line — cockroachdb,
    postgres and superset have it. user-job.yaml did get the fix, so this is docs only.

  • 7d5a721 reverted finding 5, so mosquitto ships from this PR still without icon:,
    without annotations.type and without its root-README row. Noting it for the record rather than
    holding the PR: to answer the question in your comment, https://zop.dev/logo.png is the right
    icon and what I'd have picked — it's the sanctioned fallback that scylladb, zookeeper and
    uptime-monitoring all use, and a real logo is a bucket upload away later.

@PiyushSingh-ZS
PiyushSingh-ZS merged commit 8fc08a5 into main Sep 11, 2026
1 check passed
@PiyushSingh-ZS
PiyushSingh-ZS deleted the fix/issue-320 branch September 11, 2026 11:45
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.

charts: add nodeSelector / tolerations / affinity support to workload charts

2 participants