Skip to content

feat(kubernetes): per-pool kernel modules for Talos worker nodes - #3571

Merged
Aleksei Sviridkin (lexfrei) merged 2 commits into
cozystack:mainfrom
mattia-eleuteri:feature/kubernetes-nodegroup-kernel-modules
Sep 29, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 2 commits into
cozystack:mainfrom
mattia-eleuteri:feature/kubernetes-nodegroup-kernel-modules

Conversation

@mattia-eleuteri

@mattia-eleuteri mattia-eleuteri commented Aug 6, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

Implements gap 1 of #3563: a pool-scoped kernelModules field on KubernetesNodes, emitted as Talos machine.kernel.modules.

A Talos system extension installs a kernel module but does not load it — loading is machine.kernel.modules' job. The worker machine config had no such block and no values key could add one, so a GPU pool on Talos workers was unusable as shipped: the extension supplied the NVIDIA module, nothing loaded it, and the failure was silent end to end. The VM held the PCI device, the node advertised no GPU, and no component logged an error. Found in a production 1.6.0 environment, where the worker rollover took the 4 GPUs of a tenant cluster out of service with no error anywhere.

Design: three states, and no default

The shape was arbitrated in #3563 (option 3 of the three proposed there).

  • absent — the chart chooses. A pool holding at least one nvidia.com/* GPU gets nvidia (with NVreg_NvLinkDisable=1), nvidia_uvm, nvidia_drm, nvidia_modeset; any other pool gets no kernel block at all.
  • non-empty list — taken verbatim, replacing the chart's choice outright.
  • [] — explicit opt-out: no modules even on a GPU pool.

The order is not cosmetic: Talos loads the list in sequence, nvidia has to come first because the other three depend on it, and nvidia_uvm is what CUDA unified memory needs. This is the order validated against a production GB202 passthrough node. NVreg_NvLinkDisable=1 is what the gpu-operator driver container applies on non-Talos workers; on Talos that container has to be off, so nothing else would carry it, and without it the driver waits forever for an NVLink fabric that the passed-through GPU does not have.

The three states are distinguishable only because the field carries no default — no entry in values.yaml, no default in values.schema.json. A default of [] would collapse "absent" into "opted out" for every pool and make the automatic set unreachable. The generated parameter table prints [] in the Default column for any array with no default; the field description says so explicitly. For the same reason keysOrder does not list the field, so the dashboard renders it at the bottom of the form.

Automatic emission is keyed on the nvidia.com/ resource prefix, not on the mere presence of gpus, so an AMD pool is not given NVIDIA modules on a guess.

The field loads modules; it does not put them in the image. Which extensions the image carries is the pool's schematic, so a GPU pool points osImage.factory.schematicID (or osImage.builtin.schematicID) at a schematic carrying the NVIDIA extensions.

Module names and parameters reach the reconcile Job's unquoted heredoc, so both are validated at render, and the emitted list is rebuilt from those two fields alone: the schema does not prune unknown keys, and any other key passed through toYaml would reach the shell unchecked. A parameter must be printable ASCII with no whitespace, and must not contain $, a backtick, a backslash or quotes. The ASCII half is there because RE2's \s and [:cntrl:] are ASCII-only: without it U+2028 and U+2029 pass, toYaml writes them raw, and Helm's YAML parser reads them as line breaks that end the Job's command block scalar early. Inside ASCII the guard denies what the shell expands rather than allowing a fixed set, because NVIDIA's documented multi-value parameter is semicolon-separated.

Rollout, and #3523

A pool that resolves to no modules renders the machine config it rendered before, byte for byte: tests/render_snapshot_test.yaml and its committed golden snapshot pass unmodified, including the talos-reconcile Job's content-hash name.

For a pool that does get modules, what happens depends on how its TalosConfigTemplate is named, and the field description states both cases:

  • On main today the template has a fixed name and an immutable spec, so the reconcile Job's apply of a changed machine config is refused (TalosConfigTemplate is immutable but applied on every reconcile, so machine-config changes never propagate #3515). Running workers keep what they booted with, and new workers boot from the old template too. This covers the upgrade itself: every existing pool holding an nvidia.com/* GPU with the field unset gets the automatic set, and its apply is refused the same way. Only pools created afterwards pick the modules up.
  • Once fix(kubernetes): name TalosConfigTemplate by content hash #3523 lands the template is named by its content, so the same change renames it and CAPI rolls the pool. On the upgrade that brings both, every existing pool holding an nvidia.com/* GPU with the field unset rolls onto the automatic set. That roll is the intended outcome — those pools have no working driver without it — but it is a roll, and the release note says so.

Tests

  • packages/apps/kubernetes-nodes/tests/kernel_modules_test.yaml, 12 cases: the NVIDIA set and its order for an nvidia.com/* pool, no kernel block for a pool with neither GPUs nor modules, an explicit list taken verbatim including parameters, an explicit list replacing the NVIDIA default, [] opting out while leaving the gpu=on label intact, no NVIDIA assumption for another vendor, a semicolon-separated parameter accepted, a hostile module name and a hostile parameter each rejected, a parameter carrying U+2028 and one carrying U+2029 each rejected with the kernelModules message rather than a YAML parse error, and an unvalidated extra key on an item dropped. The patterns pin absolute indentation, because modules: one level out is still valid YAML and silently loads nothing.
  • hack/talos-reconcile-heredoc_test.bats lists kernelModules in its validated arm and gains two execution-level cases: a hostile name and a hostile parameter each refused at render, and a semicolon-separated parameter surviving the real shell literally. Loosening the guards to ^.+$ makes the refusal case fail.
  • helm unittest packages/apps/kubernetes-nodes: 31 suites, 174 tests, 12 snapshots, all passing. hack/cozytest.sh hack/talos-reconcile-heredoc_test.bats: 8 passed.
  • make generate in the package, and controller-gen object for the api/apps/v1alpha1 submodule.

Not in scope

The rest of #3563: the nvidia-operator-validator → device-plugin chain that cannot validate an OS-provided driver, CDI spec generation and the nvidia containerd runtime default, and the maxSurge / nodeStartupTimeout defaults.

Screenshots

No UI changes.

Downstream repositories

Both follow-ups are issues rather than PRs. terraform-provider-cozystack is hand-written, so the field needs a schema entry, a model entry and an expand/flatten pair on KubernetesNodes; the undefaulted three-state behaviour is exactly where that provider's habit of sending its own defaults would break users silently, since materialising kernel_modules = [] would disable the automatic NVIDIA set for every GPU pool managed through Terraform. website needs the three-state behaviour written down, the GPU pool example pointed at osImage.factory.schematicID, and the Blackwell constraint documented: on GB202 the schematic must carry siderolabs/nvidia-open-gpu-kernel-modules-production, because with the proprietary extension the module loads, /dev/nvidia0 appears, and nvidia-smi -L then reports No devices found.

The hack/talos-reconcile-heredoc_test.bats change adds test cases and does not change node prerequisites, so ansible-cozystack and talm are not reached. No other repository in the map is touched: no package, platform component, bundle, variant or release asset is added, and ApplicationDefinition semantics are unchanged.

Release note

feat(kubernetes): Worker node pools (`KubernetesNodes`) accept a `kernelModules` field, emitted as Talos `machine.kernel.modules`. A Talos system extension installs a kernel module without loading it, so a GPU pool previously came up with no working driver and no way to fix it through values. Left unset, a pool holding at least one `nvidia.com/*` GPU now loads `nvidia` (with `NVreg_NvLinkDisable=1`), `nvidia_uvm`, `nvidia_drm` and `nvidia_modeset` automatically; an explicit list replaces that set, and an explicit `[]` opts out. The modules still have to be in the image: point the GPU pool's `osImage.factory.schematicID` at a schematic carrying the NVIDIA extensions, and on Blackwell (GB202) use the open kernel modules extension, not the proprietary one. Pools without GPUs and without the field render exactly what they rendered before. NOTE: while the worker `TalosConfigTemplate` keeps a fixed name (#3515), a changed module list does not reach a pool that already exists — including the automatic set on existing GPU pools — and only pools created afterwards pick it up. Once that template is named by its content (#3523), the same change rolls the pool, so existing GPU pools that leave the field unset are rolled onto the automatic set.

Summary by CodeRabbit

  • New Features
    • Worker node pools can now configure Talos kernel modules, including optional module parameters.
    • When no list is specified, NVIDIA GPU pools automatically receive NVIDIA modules; other pools receive none. A custom list replaces this selection, while an empty list disables modules.
    • Invalid module names or parameters are rejected during configuration rendering.
  • Documentation
    • Added guidance on module selection, parameters, image-extension requirements, and pool rollout behavior.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: cozystack/cozystack/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 9bd77c2a-c40c-4023-a5c1-37acac33f5f3

📥 Commits

Reviewing files that changed from the base of the PR and between c749681 and f7c04b7.

📒 Files selected for processing (10)
  • api/apps/v1alpha1/kubernetesnodes/types.go
  • api/apps/v1alpha1/kubernetesnodes/zz_generated.deepcopy.go
  • hack/talos-reconcile-heredoc_test.bats
  • packages/apps/kubernetes-nodes/README.md
  • packages/apps/kubernetes-nodes/templates/_helpers.tpl
  • packages/apps/kubernetes-nodes/templates/talos-reconcile-job.yaml
  • packages/apps/kubernetes-nodes/tests/kernel_modules_test.yaml
  • packages/apps/kubernetes-nodes/values.schema.json
  • packages/apps/kubernetes-nodes/values.yaml
  • packages/system/kubernetes-nodes-rd/cozyrds/kubernetes-nodes.yaml

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 1 remain after this review.


📝 Walkthrough

Walkthrough

The worker API and Helm chart now support per-pool Talos kernel modules. Unset values select NVIDIA modules for NVIDIA GPU pools; explicit lists replace that selection, and an empty list disables modules. The change adds input validation, tests, and configuration documentation.

Changes

Worker kernel-module configuration

Layer / File(s) Summary
Kernel-module API and values contract
api/apps/v1alpha1/kubernetesnodes/*, packages/apps/kubernetes-nodes/values.yaml, packages/apps/kubernetes-nodes/values.schema.json, packages/system/kubernetes-nodes-rd/cozyrds/kubernetes-nodes.yaml, packages/apps/kubernetes-nodes/README.md
The API and values schemas define module names and optional parameters. The documentation describes selection rules, overrides, opt-out behavior, and schematic ID formats.
Kernel-module resolution and Talos rendering
packages/apps/kubernetes-nodes/templates/*, packages/apps/kubernetes-nodes/tests/kernel_modules_test.yaml, hack/talos-reconcile-heredoc_test.bats
The chart validates module names and parameters, resolves automatic NVIDIA modules when applicable, and renders non-empty module lists into worker machine configuration. Tests cover defaults, explicit lists, empty lists, invalid values, and heredoc handling.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant PoolValues
  participant talosReconcileJob
  participant kernelModulesHelper
  participant WorkerMachineConfig
  PoolValues->>talosReconcileJob: Pass kernelModules in the group context
  talosReconcileJob->>kernelModulesHelper: Resolve modules from pool values and GPU names
  kernelModulesHelper->>WorkerMachineConfig: Provide non-empty kernel.modules configuration
Loading

Merge Risk: ⚪ Minimal · up to f7c04

This change adds per-pool kernel-module configuration with automatic NVIDIA defaults and opt-out. No merge-blocking issue was identified in the supplied changes.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to f7c04

Input checks limit the new module settings before they reach the reconciliation job, and the change does not establish a new authorization path. However, existing pools may not receive the requested machine configuration, and malformed input may be treated as an opt-out rather than rejected. These risks are pool-scoped but can leave actual worker configuration different from the requested configuration.

Retained concerns

  • Medium · reliability · inferred: For an existing pool, a newly selected module list is applied to a stable-name TalosConfigTemplate whose spec is documented as immutable. A rejected update leaves workers and newly provisioned replacements using the old configuration; per-pool image changes can therefore advance independently of the requested module configuration.
  • Medium · architecture · inferred: The application defaulting path can convert a supplied non-array kernelModules value to an empty array. Because an empty array now means explicit opt-out, malformed input may silently suppress the automatic NVIDIA module selection rather than fail as invalid configuration.
Security review details

Security Blast Radius

  • inferred — The new field expands what existing KubernetesNodes writers can request for workers in a pool, including host-level module loading. The shown change does not add a new writer or endpoint; execution occurs through the existing namespace-scoped reconciliation identity.

Security Findings and Attack Paths

  • inferred — No command-injection path through a list item is established: shell-expansion characters in names or parameters are rejected, and unrecognized item keys are discarded before rendering. Actual Job execution and admission-to-chart behavior remain unverified.

Trust Boundaries and Controls

  • inferred — The chart schema specifies an array, but the application defaulting code can replace a non-array value with an empty array before chart validation. Render-time validation protects list contents, not preservation of invalid input as an error at the API boundary.

Resilience and Maintainability Implications

  • inferred — For existing pools, rejected template updates can leave the requested host configuration unapplied. The Job’s retry and parent-deletion cleanup mechanisms do not demonstrate rollback of a changed template or convergence after an immutable update fails.

Hardening Proposals

  • proposed — Reject malformed kernelModules types at the tenant API boundary, and verify the persisted unset, empty, and explicit-list states through reconciliation rather than only in render tests.
  • proposed — Define and exercise an existing-pool migration and recovery path that keeps the MachineDeployment reference, TalosConfigTemplate, and selected worker image consistent after module or schematic changes.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 3…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding per-pool kernel module support for Talos worker nodes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/kubernetes Issues or PRs related to the tenant Kubernetes app kind/feature Categorizes issue or PR as related to a new feature labels Aug 6, 2026
@github-actions github-actions Bot added size/XXL This PR changes 1000+ lines, ignoring generated files and removed size/XL This PR changes 500-999 lines, ignoring generated files labels Aug 6, 2026
@mattia-eleuteri mattia-eleuteri changed the title feat(kubernetes): expose kernel modules for Talos worker node groups feat(kubernetes): per-node-group kernel modules and Talos schematic Aug 6, 2026

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.

NOT LGTM — two things: the new fields reach the shell unvalidated, and kernelModules does not apply to workers that already exist, which the field description does not say.

Business context: a GPU node group is not expressible on Talos workers today. Nothing loads the NVIDIA modules, and one cluster-wide schematic cannot serve a cluster that mixes GPU and non-GPU groups.

On the API shape you asked me to arbitrate in #3563: take option 3, the one you implemented. Option 1 leaves every GPU user to discover four module names and their order, and that missing knowledge is the actual gap, not the typing. Keying the automatic set on the nvidia.com/ prefix instead of on gpus being non-empty is the right call too.

Blockers

B1: kernelModules and schematicID are not validated before they reach the shell

File: packages/apps/kubernetes/templates/talos/talos-reconcile-job.yaml:401, mirrored at packages/apps/kubernetes-nodes/templates/talos-reconcile-job.yaml:303

The machine config is written through cat <<EOF | kubectl apply -f - with an unquoted delimiter, which the script needs so ${RELEASE} and friends expand. That also means the shell expands everything else in the block. kernelModules[].name, kernelModules[].parameters[] and schematicID are free-form CR strings typed as plain string, and they land there verbatim.

Evidence: rendering with kernelModules: [{name: "nvidia$(id)"}] emits - name: nvidia$(id) into command[2], and an unquoted heredoc of that shape substitutes instead of passing through (printf 'cat <<EOF\n- name: x$(echo IN)\nEOF\n' | sh prints - name: xIN). The neighbouring tenant-supplied strings in the same block are guarded before they get there: cluster.yaml runs regexMatch over systemReservedMemory, kubeReservedMemory, systemReservedCpu and kubeReservedCpu. That guard is the file's existing convention for this hazard, and the new fields skip it.

Fix: a render-time fail in both new helpers, with a failedTemplate assert next to the cases already in kernel_modules_test.yaml. ^[a-z0-9_-]+$ for module names and ^[0-9a-f]{64}$ for a schematic ID cover the real syntax. cozyvalues-gen has no pattern vocabulary, so the schema cannot carry this.

B2: kernelModules does not reach workers that already exist

File: packages/apps/kubernetes/values.yaml:94

MachineDeployment.spec.template.spec.bootstrap.configRef.name is the fixed <release>-<group> (cluster.yaml:797), so rewriting the TalosConfigTemplate leaves spec.template untouched and CAPI starts no rollout. A running Machine keeps the config it booted with. I rendered a GPU group with neither field set against main: the KubevirtMachineTemplate hash is identical, only the Job hash moves.

Impact: an operator with the dead GPU node group from #3563 upgrades, gets the automatic NVIDIA set, and still has no GPU until the Machines are replaced. The schematicID description states its rollout semantics; kernelModules says nothing, so the asymmetry reads as "this one applies immediately".

Fix: one sentence in the values.yaml annotation. It propagates to README, schema and CRD.

Non-blocking follow-ups

  1. #3294 is open and adds a second surface for the same value: nodeGroups.<name>.image.builtin.schematicID and image.factory.schematicID, both falling back to the cluster-wide talos.schematicID. It edits the same two files, so whichever lands second conflicts textually, and merging both leaves two ways to set one thing with no precedence. This PR is the better base for it. #3294's own description says its factory.schematicID redirects only the boot-disk import while the installer keeps coming from the cluster-wide value, which is exactly the divergence this PR closes by routing both through one helper.

  2. hack/e2e-talos-image-cache.yaml pre-fetches a single schematic. Nothing to do now, since no e2e case sets a per-group override, but one that does would miss the cache and fall back to the public factory that cache exists to avoid.

What I checked rather than took from the description: reordering the module list in one chart's helper makes render-parity.sh exit 1 on the gpu and schematic cases, and it exits 0 unmutated, so the machine-config comparison is a real gate. With no new fields set, the md0 template and Job hashes match main exactly. The rendered kernel.modules block lands at the right level, and the per-group schematic reaches both the boot image URL and the installer. make generate in both packages produces no drift.

@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

Both blockers fixed in c6444ab. One correction to follow-up 1, with evidence, because it changes who should own the schematic field.

B1 — validated before the shell

Reproduced first: rendering kernelModules: [{name: "nvidia$(id)"}] emitted - name: nvidia$(id) into command[2], and printf 'cat <<EOF\n- name: x$(echo IN)\nEOF\n' | sh prints - name: xIN, so the unquoted delimiter substitutes as you described.

Guarded all three at render time with regexMatch + fail, following the convention cluster.yaml already uses for the kubelet reservation strings in the same block:

  • kernelModules[].name → ^[a-z0-9_-]+$
  • kernelModules[].parameters[] → ^[A-Za-z0-9_.,:=+/-]+$, which admits NVreg_EnableGpuFirmware=1 and excludes $, backticks, quotes and whitespace
  • schematicID → ^[0-9a-f]{64}$

Two choices worth flagging rather than burying:

Module names and parameters are validated only when the operator supplied the list, mirroring the comment on the kubelet guards — the automatic NVIDIA set is valid by construction. schematicID is validated on the effective value instead, so it also closes the pre-existing path through the cluster-wide talos.schematicID, which reached the same heredoc before this PR. That does tighten an existing field: a cluster carrying a non-64-hex talos.schematicID today renders fine and would now fail. Every image-factory digest is 64 lowercase hex and anything else 404s at the factory, so I judged a loud render failure better than a VM stuck importing a URL that cannot resolve — but say the word and I will scope the guard to the per-group override only.

failedTemplate cases sit next to the existing ones in kernel_modules_test.yaml and the schematic suites, four in total. The schematic ones carry a template: scope with a comment saying why: the guard is in a shared helper reached from both templates in the suite, and Helm aborts on the first to hit it, so the assert names the template that reports the failure rather than the only one able to.

B2 — documented

You are right that this is not just #3515. configRef.name is the fixed <release>-<group> at cluster.yaml:797, so the MachineDeployment's spec.template never changes and CAPI starts no rollout; a running Machine keeps the config it booted with, independently of whether the apply succeeded. Added to the values.yaml annotation in both charts, so it propagates to README, schema and CRD, and stated the asymmetry explicitly — schematicID rolls the group by changing its boot image, kernelModules does not roll anything by itself.

Follow-up 1 — #3294 already does this, more completely

Checking it, I do not think the schematic field should stay in my PR at all, and the reason cuts against what you wrote. The NOTE you are quoting is about imageFactoryURL, not the schematic: it says redirecting a group's factory URL does not redirect the installer, because talos.installerRepository has no per-group override. On the schematic #3294 already routes both consumers per group. Its reconcile job renders image: {{ ... }}/{{ .talosSchematicID }}:{{ .talosVersion }}, with $grpSchematicID resolved from image.builtin.schematicID or image.factory.schematicID falling back to the cluster-wide value, and it ships tests pinning exactly that (install.image pins the group's per-group schematicID/version, and a fallback case). So it is not the divergence my helper closes — it closes it too, and adds per-group version and the golden-clone path on top.

That makes my flat nodeGroups.<name>.schematicID a strict subset of nodeGroups.<name>.image.factory.schematicID, which is exactly the two-ways-to-set-one-thing you want to avoid, and #3294 is the richer surface. Concretely, the two also collide in a way that shows the duplication is not benign: #3294's test uses schematicID: deadbeef, which my new ^[0-9a-f]{64}$ guard rejects.

My proposal, yours to overrule:

If instead you want the flat field kept as the canonical one and #3294's union to defer to it, that works mechanically, but I would argue against: #3294 needs per-group version and builtin anyway, so the union has to exist regardless, and a flat field beside it is the redundancy rather than a simplification.

Follow-up 2 — e2e image cache

Noted, nothing changed. No e2e case sets a per-group override, so nothing misses the cache today. Worth saying that #3294 removes hack/e2e-talos-image-cache.yaml outright and has the suites clone a golden instead, so if the schematic field lands there this concern resolves itself rather than needing a guard here.

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.

NOT LGTM — the schematic guard rejects a configuration that renders on main today.

Both previous blockers are closed. I re-ran the payload against this head: nvidia$(id) now fails the render in both charts, and validating the effective value closes the cluster-wide path as well, which is the better of the two options you offered. Weakening ^[a-z0-9_-]+$ to ^.*$ turns kernel_modules_test.yaml red, so the new asserts are load-bearing rather than decorative. The B2 wording is accurate and landed in both charts.

You are right about #3294 and I was wrong. The NOTE I quoted sits on imageFactoryURL, not on the schematic, and #3294 does route the per-group schematic into install.image through .talosSchematicID, with a test pinning it. My follow-up 1 argued from a misreading of that field. Disregard the reasoning; the ownership question it raised is answered below on different grounds.

Blockers

B3: the 64-hex rule rejects air-gapped and mirrored configurations that work today

File: packages/apps/kubernetes/templates/_helpers.tpl:283, mirrored in packages/apps/kubernetes-nodes/templates/_helpers.tpl

^[0-9a-f]{64}$ on the effective value encodes the public factory's naming as the only legal one. The chart supports more: talos.imageFactoryURL documents "a self-hosted Image Factory, a caching mirror, or an internal HTTP file server", and talos.installerRepository documents mirrored registries. On a file server or a mirrored registry the operator picks the path, and a readable name is the obvious choice.

Evidence: values carrying imageFactoryURL: http://images.internal.example.com/talos, installerRepository: registry.internal.example.com/talos/installer and schematicID: talos-gpu-nvidia-open render on main into .../image/talos-gpu-nvidia-open/v1.13.6/openstack-amd64.raw.xz and .../installer/talos-gpu-nvidia-open:v1.13.6, and fail the render on this head. Same values, same command, only the checkout differs. #3294's schematicID: deadbeef test hits the same wall, which makes it two independent cases rather than one contrived one.

Impact: a cluster running against a mirror stops reconciling after the upgrade, on a field its operator never touched.

Fix: make the guard the injection guard the comment says it is, instead of a format guard. A class such as ^[A-Za-z0-9._:-]+$ on the effective value rejects $, backticks, quotes and whitespace, keeps the cluster-wide path closed, and leaves mirror naming alone. A wrong ID still 404s at the factory, exactly as it did before this PR, and that failure is visible in the DataVolume rather than hidden.

On who owns the schematic field

Keep it here for now, and I will take the redundancy. #3294 is the richer surface and should own this in the end, but it is not close to landing: it brings a new package, a golden catalog and an e2e rework, while the reboot loop is live. When #3294 rebases, have image.factory.schematicID fall back to nodeGroups.<name>.schematicID rather than replace it, so nothing that shipped breaks, the union stays canonical, and the flat field can be deprecated on its own schedule. One documented redundancy for a release is cheaper than leaving a production reboot loop waiting on a large PR.

Follow-up 2 is answered and nothing is owed there: #3294 deletes hack/e2e-talos-image-cache.yaml outright, so the concern resolves itself if the schematic ends up there.

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.

NOT LGTM — the injection guard is bypassable. This lands on the same head as my previous review, which I posted before finishing this pass.

B4: any extra key on a kernelModules item walks past the guard

File: packages/apps/kubernetes/templates/_helpers.tpl:194, and the same copy in packages/apps/kubernetes-nodes/templates/_helpers.tpl

The validator walks .name and .parameters, but the emit is toYaml over the raw user dict, so every other key on the item is copied into the heredoc verbatim.

Evidence: a group carrying

kernelModules:
  - name: dummy
    evil: "$(touch /tmp/pwned)"

renders - evil: $(touch /tmp/pwned) inside cat <<EOF | kubectl apply -f -, in both charts. Nothing upstream catches it either: the items object in values.schema.json has no additionalProperties: false, and the aggregated apiserver does not enforce that schema on write. specSchema in pkg/registry/apps/application/ is wired into rest_defaulting.go only, with no pruning and no validation on the Create or Update path. So the render is the last gate, exactly as your comment in the helper says.

Fix: build the emitted list from validated fields, dict "name" $name "parameters" $params, instead of passing user input through toYaml. Then anything unvalidated cannot reach the output by construction rather than by enumeration. A third case in kernel_modules_test.yaml with an extra key carrying $(...) fails today and pins it.

B5: review provenance in two committed test files

packages/apps/kubernetes/tests/kernel_modules_test.yaml:260 and packages/apps/kubernetes-nodes/tests/kernel_modules_test.yaml:156 both open with # B1 from review:. Comments here are held to the same standard as commit messages: self-contained, no review-iteration references, because the next reader has no access to this thread. The rest of the sentence is good, just drop the first four words.

Smaller

schematic_per_nodegroup_test.yaml has the $(id) rejection case but not the plain not-64-hex one that schematic_per_pool_test.yaml:117 carries. And since the guard now constrains the effective value, talos.schematicID's own description should say so instead of reading as a free-form override. Both of these are moot if B3 changes the guard's shape.

One sentence is also worth adding to the kernelModules description: the automatic NVIDIA set keys on the nvidia.com/ prefix alone, with no relation to whether the effective schematic actually carries those modules. In Talos v1.13 a module that cannot load leaves the kernel-module controller restart-backing-off rather than bricking the node, so it is a soft failure, but it is the same silent-mismatch class the schematic half of this PR exists to remove.

Not yours to fix here: talos.installerRepository and talos.version reach the same heredoc unvalidated and are unchanged from the merge base. I am tracking those separately.

@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

B3, B4 and B5 fixed in 88ae4de, plus the smaller items. Both blockers reproduced against the merge base first.

B3 — the guard was a format rule, not an injection guard

Confirmed the regression before changing anything: values carrying imageFactoryURL: http://images.internal.example.com/talos, installerRepository: registry.internal.example.com/talos/installer and schematicID: talos-gpu-nvidia-open render on main into .../image/talos-gpu-nvidia-open/v1.13.6/openstack-amd64.raw.xz and fail on the previous head. Same values, same command, only the checkout differs — so it broke a cluster on a field nobody touched, which is not a trade I should have made. I flagged the tightening and still misjudged it: I reasoned from the public factory's naming and treated the documented mirror and file-server paths as though they did not exist.

Now ^[A-Za-z0-9._:-]+$ on the effective value, as you proposed. It rejects $, backticks, quotes and whitespace, keeps the cluster-wide path closed, and leaves mirror naming alone. A wrong ID 404s at the factory exactly as before, on the DataVolume where it is visible.

Both charts now pin the mirror case as accepted — readable name, internal factory URL, mirrored installer repository, asserting both rendered image references — so this cannot regress into a format rule again. The not-64-hex case in schematic_per_pool_test.yaml was itself testing the wrong rule and is replaced by sch`whoami`, which the class does reject, mirrored into the parent chart's suite so the two are symmetric.

B4 — closed by construction

Reproduced: - {name: dummy, evil: "$(touch /tmp/pwned)"} rendered - evil: $(touch /tmp/pwned) inside the heredoc in both charts. I also checked the two upstream claims rather than take them: items in both values.schema.json files has no additionalProperties: false, and specSchema in pkg/registry/apps/application/ appears only in rest_defaulting.go and the constructor in rest.go, with no pruning or validation on the write path. So the render is the last gate, and enumerating fields was the wrong shape for the fix.

The emitted list is now rebuilt as dict "name" $name plus parameters when non-empty, from the validated values, so an unknown key cannot reach the output at all. Rendering for valid input is byte-identical — the explicit br_netfilter + parameters case and the render-parity comparison both still pass unchanged. A case in kernel_modules_test.yaml in each chart pins it: the extra key is absent and - name: dummy is still emitted.

B5 — dropped

Both files now open with "These values reach…". Point taken on the standard: a test comment referencing a review round is dead weight to whoever reads it next.

Smaller items

  • talos.schematicID's own description now says the effective per-group value is constrained to ^[A-Za-z0-9._:-]+$ and why, and that both a 64-hex digest and an operator-chosen mirror path satisfy it.
  • kernelModules now says the automatic set keys on the nvidia.com/ prefix alone with nothing cross-checking that the effective schematic carries those modules, and that on Talos v1.13 such a module leaves its controller retrying rather than failing the boot, so the symptom is a missing driver rather than a dead node. I took the v1.13 behaviour from your description and scoped the sentence to that version rather than stating it unqualified, since I have not verified it myself.
  • talos.installerRepository and talos.version left alone, as you said.

Ownership

Taking your call: the field stays here, and when #3294 rebases image.factory.schematicID falls back to nodeGroups.<name>.schematicID rather than replacing it, so the union stays canonical and nothing that shipped breaks. I have noted that on #3294 so it is not discovered at rebase time. Once it lands I will open the deprecation of the flat field rather than leave the redundancy undated.

State on this head: 207 tests in the parent chart, 23 in kubernetes-nodes, render-parity green including the machine-config comparison and the new schematic case. Rendering for a node group setting neither field is still byte-identical to main, KubevirtMachineTemplate and Job content hashes included, so nothing rolls.

@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

Unit & controller tests is red on this head and it is not this PR. Filing the detail so nobody re-derives it.

The failing test is the unconverted bats files hold no more EXIT traps than they already did in hack/cozyreport.bats. hack/multus-install-cni-plugins.bats arrived on main in 7739d1e carrying 12 EXIT traps, one day before that ratchet was written, so the frozen list never learned about it. The guard fails on main itself, and this PR's CI builds a merge with main.

Reproduced against a pristine checkout of main at 879d0f6, with none of this branch: the guard's found set differs from frozen by exactly multus-install-cni-plugins.bats=12. Fix is a one-line count update, which is what the guard's own comment prescribes for a file that predates it — #3584. This PR goes green on its own once that lands; nothing to rebase here.

In the same run, everything else that matters passed: 70 helm suites with none failing, including all four suites this PR adds and GOLDEN PARITY on the render-parity check, plus pre-commit, Verify generated code is up to date, CodeQL and the API owner gate. Exactly one ❌ Test failed in the whole job, and it is the ratchet.

Separately, while probing the same class as B4 I checked the two remaining shapes an unenforced schema would let through, and both are already closed: Helm validates values against values.schema.json at render time, so a bare string in place of a module item (kernelModules: ["nvidia"]) and a scalar where parameters should be a list are both rejected before any template runs. What the schema does not reject is extra keys, because it carries no additionalProperties: false — which is exactly B4, and is now closed by construction rather than by enumeration. So the field is guarded on three independent axes: types by the schema, characters by the regex, keys by rebuilding the emitted list.

@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

One more commit, 665c3f2, which changes something you already verified — flagging it rather than letting it slip past.

The automatic NVIDIA set now carries NVreg_NvLinkDisable=1 on the nvidia module. Reason: the chart already applies that parameter to every tenant GPU cluster today. cozystack.defaultGpuOperatorValues turns on kernelModuleConfig, whose ConfigMap content in packages/system/gpu-operator/values.yaml is exactly that one line, and the operator's driver container appends it to the nvidia modprobe options. Cozystack passes individual GPUs into worker VMs without the NVSwitches, so without it the driver waits forever for a fabric that cannot come up — Fabric State stays In Progress and every CUDA call fails with system not yet initialized.

On Talos that driver container is disabled (the driver comes from the system extension), so nothing applies the parameter and machine.kernel.modules is the only place left to carry it. A GPU node group moved to Talos loses it silently, which is the same class of quiet failure this PR exists to remove. So the automatic set reproduces what the chart already chose rather than making every operator rediscover it. It is a no-op on a PCIe card with no NVLink, and an explicit kernelModules list still replaces the set outright, so an operator who genuinely has a fabric can drop it.

Consequences in the diff: the pinned auto-set patterns in both charts now include the parameter, the GPU group's content-hash fixture in talos_templates_test.yaml moves again (the non-GPU one still does not), and the field descriptions name the parameter and say why Talos cannot inherit it. Rendering for a node group setting neither field is still byte-identical to main.

This is the first half of the gap 3 + gap 4 work from #3563. The second half — a gpu-operator addon default for the OS-provided-driver shape, which is what makes driver.enabled: false correct in the first place — is coming as its own PR against main, since it touches a different file and should not enlarge this one further.

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.

NOT LGTM — the schematic class I suggested last round is one character too narrow, and it rejects a value that renders on main. My recommendation, my miss; details below.

Everything else from the previous rounds is closed and I re-ran the checks rather than reading the diff. The allowlist closes B4 by construction: the extra-key payload now renders - name: dummy and nothing else, a nested extra.nested.deep: "$(...)" is dropped the same way, and swapping the rebuild back to deepCopy . turns kernel_modules_test.yaml red, so the new case is load-bearing. Both injection paths stay closed, including the effective-value one through the cluster-wide field. B5 is gone from both files. The schematic suites gained a positive case pinning a readable mirror name, so that boundary is now tested rather than incidental.

On 665c3f21: for a GPU node group setting neither field the KubevirtMachineTemplate hash is unchanged against main, so the parameter does not roll an existing GPU group by itself, and the non-GPU group stays byte-identical on both objects. Suites are green in both charts (207 and 23), GOLDEN PARITY passes, make generate leaves no drift.

Blocker

B6: the schematic class has no /, and a mirror path needs one

File: packages/apps/kubernetes/templates/_helpers.tpl:320, mirrored in packages/apps/kubernetes-nodes/templates/_helpers.tpl

Evidence: same values file at both revisions, imageFactoryURL: http://images.internal.example.com/talos, installerRepository: registry.internal.example.com/talos/installer, schematicID: gpu/nvidia-open. Main renders http://images.internal.example.com/talos/image/gpu/nvidia-open/v1.13.6/openstack-amd64.raw.xz and image: registry.internal.example.com/talos/installer/gpu/nvidia-open:v1.13.6, both well-formed, since an OCI repository path takes multiple segments. This head fails the render. That is the same regression class as B3, one character narrower, and the error text plus all four field descriptions call the value "the path an operator chose", which is exactly what a class without / forbids.

Fix, and the shape of it matters more than the character: / is inert in an unquoted heredoc, as are ;, |, & and parentheses. Only $, a backtick and a backslash are special there. So the guard is better written as a rejection of those three plus quotes and whitespace, rather than as an allowlist of permitted characters. An allowlist is right for kernelModules items because the set of legal keys is closed and the chart owns it, which is why B4's fix is correct. Here the set of legal values is open, it belongs to whatever registry or file server the operator runs, and every round of enumerating it has cost a legitimate configuration. Extend the existing positive case with a path-shaped value so the boundary is pinned.

Recommended

The parameters class bars ;, and NVIDIA's own multi-value form needs it: NVreg_RegistryDwords="PowerMizerEnable=0x1;PerfLevelSrc=0x2222" is the documented shape, semicolon-separated key=value pairs read by the module at load time. Same reasoning as above, and worth widening while that class is being touched.

Two wordings over-claim slightly. "Byte-for-byte the same helper as the parent kubernetes chart's" is not literally true: the fail messages differ (nodeGroup %s: against pool %s:) and so do the comments. What is identical is the emitted output, which render-parity.sh proves. And "that container is disabled there" describes a configuration the chart does not produce: templates/helmreleases/gpu-operator.yaml sets only kernelModuleConfig.create and driver.kernelModuleConfig.name, so on Talos an operator still has to disable the driver container through addons.gpuOperator.valuesOverride. The conclusion holds, only the passive voice claims the chart already did it. That sentence lives in six generated copies, so it is one edit plus make generate.

Outside this PR

talos.installerRepository and talos.version reach the same unquoted heredoc unvalidated, unchanged at the merge base, and the support-matrix guard does not catch the version one because regexFind extracts the prefix and the membership check that follows tests the Kubernetes version rather than the Talos one. Not yours to fix here, and I am filing them separately, but the new field descriptions now assert the sink is constrained, which reads as broader coverage than exists.

The red Unit & controller tests is not this branch. I reproduced it on a pristine checkout of main at 879d0f6: the EXIT-trap ratchet in hack/cozyreport.bats differs from its frozen set by exactly multus-install-cni-plugins.bats=12, so every PR inherits it through the merge commit CI builds.

One non-blocking note for the terraform provider follow-up rather than for this chart: kernelModules []KernelModule with omitempty in the generated types cannot represent the [] opt-out, because an empty slice serialises away and becomes indistinguishable from unset. The in-cluster path is unaffected, since the CR spec travels as raw JSON and never passes through those structs, so applying YAML behaves as documented. A typed Go client building the object would silently lose the opt-out.

@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

The NVreg_NvLinkDisable=1 addition from 665c3f2 is now verified on hardware, not just argued from the ConfigMap.

On a fresh single-node tenant cluster with an L40S and a machine.kernel.modules block byte-identical to what this branch renders for a nvidia.com/* node group:

$ cat /proc/modules | grep ^nvidia
nvidia_uvm 2068480 0 - Live
nvidia_drm 159744 0 - Live
nvidia_modeset 2150400 1 nvidia_drm, Live
nvidia 15994880 7 nvidia_uvm,nvidia_modeset, Live

$ grep -i nvlinkdisable /proc/driver/nvidia/params
NvLinkDisable: 1

So the parameter does reach the driver through the machine config, which is the part I could only infer before. The four modules load in the declared order, nvidia-cuda-validator reports Succeeded, the node advertises nvidia.com/gpu: 1, and a tenant pod gets GPU 0: NVIDIA L40S (UUID: …) from nvidia-smi -L. The node has held one bootID for 93 minutes, so nothing about the block destabilises the boot.

Two honest limits. The run cannot show the parameter is needed: an L40S is PCIe with no NVLink, so this establishes that it is harmless and that CUDA initialises with it — its necessity rests on the SXM passthrough case in production. And the modules were added by hand, since this branch is not released; what was verified is the rendered content, not the chart applying it.

Useful diagnostic side-note, since /proc is where this is visible: /proc/modules and /proc/driver/nvidia/params are not namespaced, so a plain unprivileged pod can read both. /sys/module/nvidia/parameters/* cannot be read without root, which is the wrong place to look.

Also for the record: that cluster has a single GPU node group, and it does not show the ~70 minute reboot loop — consistent with the loop being confined to node groups whose nodes do not match the cluster-wide schematic, which is what the schematicID half of this PR exists to fix.

@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

B6 fixed in c1ea547, along with both recommendations and both wording corrections. Verified your evidence before changing anything, and the mistake was the shape, not the character — twice now, which is the actual lesson.

B6 — denylist, not a class

Reproduced: with imageFactoryURL: http://images.internal.example.com/talos, installerRepository: registry.internal.example.com/talos/installer and schematicID: gpu/nvidia-open, main renders .../talos/image/gpu/nvidia-open/v1.13.6/openstack-amd64.raw.xz and installer/gpu/nvidia-open:v1.13.6, and the previous head failed the render.

Checked the premise rather than taking it: printf 'cat <<EOF\nA: a/b;c|d&e(f) g\nB: $(echo SUBST)\nC: echo BACKTICK\nEOF\n' | sh prints the first line verbatim and expands only the last two. So / ; | & ( ) and spaces are inert and $, backtick, backslash are not. Both guards now deny those three plus quotes and whitespace, instead of permitting an enumerated set. The allowlist stays where you said it belongs — kernelModules item keys, a closed set the chart owns.

One addition beyond your fix, from testing it: \s covers only tab, newline, form feed, carriage return and space, so a bare control character passed the class and surfaced later as yaml: control characters are not allowed, pointing at the template rather than at the value. [:cntrl:] is in the class now. Not a shell concern — it corrupts the YAML the heredoc carries, which is the other thing these guards are for.

Worth recording how I nearly reported the wrong result here: my first pass tested the backslash through --set-string and then through a values file, and both times the shell and YAML ate the escape before Helm saw it, so it read as ACCEPTED. It is rejected; a five-line template evaluating regexMatch against a literal sch\back returns false. The harness was wrong, not the guard.

Recommended, both taken

parameters takes the same denylist, so NVreg_RegistryDwords=PowerMizerEnable=0x1;PerfLevelSrc=0x2222 renders — pinned by a new case.

Both over-claims corrected. The nodes chart no longer says "byte-for-byte the same helper": the fail messages differ (pool against nodeGroup) and so do the comments, and what is identical is the emitted output, which render-parity.sh proves. And the NvLinkDisable rationale no longer says the driver container "is disabled" on Talos, since the chart does not do that — an operator turns it off through addons.gpuOperator.valuesOverride today, which is exactly what #3585 changes. That sentence lives in the generated copies, so it went through make generate.

On your point about the descriptions reading as broader coverage than exists: fixed too. They now say the guard covers this field only, not every value reaching that heredoc.

Boundaries now pinned rather than incidental

Both charts accept a multi-segment mirror path and a semicolon-bearing parameter, and still reject $(id), a backtick, a backslash, quotes and whitespace on the effective schematic. Suites green in both charts (208 and 25), GOLDEN PARITY passes, make generate idempotent, and a node group setting neither field still renders byte-identically to main including both content hashes.

The Go omitempty note

Good catch, and it is the sharper half of the terraform follow-up rather than a chart issue. Recorded on cozystack/terraform-provider-cozystack#20: []KernelModule with omitempty cannot represent the [] opt-out, so a typed Go client building the object drops it silently, while the YAML path is unaffected because the CR spec travels as raw JSON and never passes through those structs. That makes the provider's flatten side the place where the three states have to be preserved deliberately.

Also confirming your ratchet finding independently reproduced mine — #3584 is the one-line fix for it.

Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Aug 7, 2026
….bats (#3584)

<!-- Thank you for making a contribution! Here are some tips for you:
- Use Conventional Commits for the PR title: `type(scope): description`
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Scopes are not an exhaustive list — pick the most specific scope for
the change and extend the list when a genuinely new area appears.
Examples:
- System components: dashboard, platform, operator, cilium, kube-ovn,
linstor, fluxcd, cluster-api
- Managed apps: postgres, mariadb, redis, kafka, clickhouse,
virtual-machine, kubernetes
- Development and maintenance: api, hack, tests, ci, docs, maintenance
- Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or
add a `BREAKING CHANGE:` footer
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does

`hack/multus-install-cni-plugins.bats` arrived on main in 7739d1e
carrying 12 EXIT traps, one day before the EXIT-trap ratchet in
`hack/cozyreport.bats` was written, so the frozen list never learned
about it. The guard therefore fails on main itself, and on every PR
whose CI builds a merge with main — `make bats-unit-tests` exits 1 with:

```
FAIL: the set of unconverted EXIT-trap files changed.
  frozen: ... nightly-mirror_test.bats=5 ...
  found:  ... multus-install-cni-plugins.bats=12 nightly-mirror_test.bats=5 ...
```

This records the count, which is what the guard's own comment prescribes
for exactly this case: *"a file that did not exist when this guard was
written arrives carrying its own \[traps\] ... Counts are updated rather
than the files converted: all of them are owned by other branches, and a
conflict there costs more than an uncovered trap."* Converting the file
is not in scope here and belongs to whoever owns the multus branch.

Verified by reproducing the guard's logic against a pristine checkout of
`main` at 879d0f6: the `found` set differs from `frozen` by exactly
this one entry before the change, and matches after it. Found while
investigating a red `Unit & controller tests` on #3571, which carries
none of this.

### Screenshots

No UI changes.

### Screenshots

<!-- REQUIRED for UI changes: attach screenshots or screen recordings
demonstrating
the visual impact of your changes. PRs with UI changes without
screenshots will not be merged. -->

### Downstream repositories

<!--
Cozystack is upstream for repositories that are NOT kept in sync with it
automatically, and nothing in CI compares the two sides, so a change
here can break them silently.

Which change forces what, and which file to touch, is the trigger map in
docs/agents/contributing.md under "Downstream Repositories". Walk it
before you tick anything.

IF YOU ARE AN AI AGENT OR AN LLM, THIS IS PART OF THE TASK, NOT A FORM
TO FILL IN:

1. Walk the trigger map against the actual diff of this PR, file by
file. Do not decide from the PR title or from your memory of what you
changed.
2. For every repository you tick, open the follow-up PR there and link
it on the same line. A ticked box with no link claims work that does not
exist, which is worse than an empty box. Search that repository for an
open PR or issue covering it first, and link that instead of filing a
duplicate.
3. When the follow-up is out of scope, or needs a decision that is not
yours to make, open an issue in that repository instead, link that, and
say so in the PR body. Do not push a speculative PR into another
repository just to fill a line here.
4. Do not tick "No downstream repository is affected" to make the
checklist go away. If you are unsure, leave every box empty and say so
in the PR body, so a human decides.
-->

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:

### Release note

<!--  Write a release note:
- Explain what has changed internally and for users.
- Start with the same `type(scope):` prefix as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->

```release-note
NONE
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Updated the expected EXIT-trap inventory to include
`multus-install-cni-plugins.bats`.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

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.

LGTM.

B6 is closed in both charts, and I checked it by rendering rather than by reading the diff. With imageFactoryURL: http://images.internal.example.com/talos and installerRepository: registry.internal.example.com/talos/installer, gpu/nvidia-open renders http://images.internal.example.com/talos/image/gpu/nvidia-open/v1.13.6/openstack-amd64.raw.xz and registry.internal.example.com/talos/installer/gpu/nvidia-open:v1.13.6 (the same output main produces), and so do talos-gpu-nvidia-open, deadbeef and a 64-hex digest, in packages/apps/kubernetes and packages/apps/kubernetes-nodes alike. The rejection side holds: $(...), a backtick, a backslash, both quote characters, a space, a real tab, a real newline and a bare control character all fail the render, each naming the group or the pool. ; | & ( ) > ~ * pass, which is the point of the reshape.

The denylist is also complete for the sink it guards, not merely wider than the last one. Inside an unquoted here-document the shell performs parameter expansion, command substitution and arithmetic expansion, and \ keeps its meaning only before $, a backtick, \ or a newline, so those three are the whole set, and everything else in the class is there for YAML integrity rather than for the shell. Validating the effective value keeps the cluster-wide path closed; talos.schematicID: nvidia$(id) with no per-group override still fails.

Both guards are pinned in both directions and in both charts. Reverting the schematic class to ^[A-Za-z0-9._:-]+$ (last round's suggestion, the one missing /) turns schematic_per_nodegroup_test.yaml and schematic_per_pool_test.yaml red; widening both guards to ^.*$ turns all four suites red. As they stand the suites are green (208 and 25), GOLDEN PARITY passes, and make generate in both packages leaves no drift.

The parity script's new machine-config comparison is a real gate, which matters more than usual here because it is the only thing holding the two copies of the helper together. Adding one rendered line to the nodes chart's kernel: block fails it on three cases, and cutting the block out entirely fails the same three. Worth knowing for whoever tests it next: a {{- /* ... */}} comment renders nothing and so passes, which is the check being right rather than asleep. I also diffed the two helper bodies by hand. They differ only in nodeGroup against pool in the fail messages.

NVreg_NvLinkDisable=1 lands where it should and nowhere else. It appears only when the group resolves to the automatic set; an explicit list is emitted verbatim with nothing added, [] still emits no kernel block on a GPU group, and a non-NVIDIA vendor still gets nothing. Against the merge base with neither new field set, the only difference in the entire render is the GPU group's talos-reconcile Job: new content hash, new module block. Every KubevirtMachineTemplate name is unchanged and the non-GPU group is byte-identical, so no worker is rolled. The hardware run answers the half I cannot reach by rendering, which is whether the parameter actually arrives at the driver.

Both wording corrections landed, in all twelve files carrying the description, and the schematic descriptions now say the guard covers that field alone.

On the semicolon: you took the bare form and I had quoted the modprobe.conf spelling at you, which is the wrong shape for a YAML list element. NVreg_RegistryDwords=PowerMizerEnable=0x1;PerfLevelSrc=0x2222 renders, the field description and the error text both prescribe it, and the two agree. Nothing owed.

Recommended

The semicolon regression is pinned in one chart out of two. kernel_modules_test.yaml in kubernetes-nodes gained accepts a semicolon-separated module parameter; the parent chart has no equivalent. I narrowed only the parent's parameters class back to bar ; (the exact defect that case exists to prevent), and the parent suite stayed 208/208, the nodes suite stayed 25/25, and GOLDEN PARITY passed. The parity script does not cover it either, since its kernelmodules case carries nf_conntrack_helper=0. It is the same asymmetry you closed on the schematic side when the parent gained the readable-name case, and the same fifteen lines in the other file.

Two claims in the PR body have drifted from the branch. "New kernel_modules_test.yaml in both packages, 6 cases each" is 9 in the parent and 10 in the nodes chart now. And "reordering the module list in one chart's helper only makes the new MachineConfig(talos-reconcile) assertion fail, for the GPU case only" is off too: reordering the nodes chart's automatic set fails on gpu and on schematic, because the schematic case also carries an nvidia.com/* GPU and inherits the same automatic list. The body becomes the merge commit, so both are worth a pass before this leaves draft.

CI

None of the red is this branch. All twenty-seven Build packages/* jobs die at exporting to image with failed to push iad.ocir.io/..., on packages this PR does not touch (mariadb, clickhouse and metallb among them), so it is a registry-side failure and a re-run question. Unit & controller tests is still the EXIT-trap ratchet in hack/cozyreport.bats that every PR inherits through the merge with main. The run dates from the day the head commit was pushed, and nothing since asks for a code change here.

@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

Merged main and resolved. The guard and the escaping are kept at every site rather than one side being picked.

The installer image line now carries both: main's escape chain on all three coordinates, and this branch's kubernetes.schematicID include so the per-node-group schematic still resolves. nodegroup.yaml keeps both new group keys (logSerialConsole and schematicID). render-parity.sh keeps main's guest-console-log case alongside the kernel-module, opt-out and schematic cases, and keeps this branch's machine-config comparison, which supersedes the "four pool objects" scope note it replaced.

One thing worth flagging because it touches a file from #3513 rather than one this PR owns. Two cases in hack/talos-reconcile-heredoc_test.bats set a hostile talos.schematicID and asserted the heredoc kept it literal. That value is now refused at render time by this branch's guard, so those two cases went red on the merge. The INVARIANT above the data block admits escaping or render-time validation, and schematicID now takes the second route, so the resolution was to keep the hostile installerRepository and version in those cases (they only have the escaping) and move the schematicID half to a new pair of cases asserting the opposite property: the render is refused and no heredoc is produced at all. The escape chain on schematicID stays in the template as a second line of defence, unreachable for hostile input while the guard holds. Happy to reshape that if you would rather the bats file stayed as you wrote it.

Checks after the merge: 223 tests in packages/apps/kubernetes, 31 in packages/apps/kubernetes-nodes, all 7 cases in hack/talos-reconcile-heredoc_test.bats, GOLDEN PARITY clean, make generate leaves no drift in either package and api/apps/v1alpha1 builds with every type covered by a deepcopy. The default render of the reconcile Job is byte-identical to main's, so the merge rolls no worker.

Also took the two recommended items. The parent chart gained accepts a semicolon-separated module parameter, the case that existed only in kubernetes-nodes; narrowing the parent's parameters class to an allowlist barring ; turns it red on the guard's own error message, so it is load-bearing. And both drifted claims in the body are corrected: 10 cases each rather than 6, and the reordering check fails on gpu and schematic rather than the GPU case alone, which I reproduced by swapping nvidia_drm and nvidia_modeset in the nodes chart's automatic set.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🧹 Nitpick comments (3)
packages/apps/kubernetes-nodes/tests/schematic_per_pool_test.yaml (1)

103-115: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case that pins validation of the cluster-wide talos.schematicID.

The comment states that validating the effective value also closes the pre-existing path through talos.schematicID. No test covers that path. A future change that moves the guard to the per-pool override only would keep every test green.

🧪 Proposed extra case
+  - it: rejects an unsafe cluster-wide schematicID inherited by the pool
+    set:
+      talos:
+        schematicID: "$(id)"
+    asserts:
+      - failedTemplate:
+          errorPattern: 'invalid schematicID'
+        template: templates/talos-reconcile-job.yaml
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/apps/kubernetes-nodes/tests/schematic_per_pool_test.yaml` around
lines 103 - 115, Extend the schematic validation tests with a case that sets the
cluster-wide talos.schematicID to a shell-injection value while leaving the
per-pool override unset. Assert Helm rendering fails with the existing “invalid
schematicID” error through templates/talos-reconcile-job.yaml, proving
validation covers the effective cluster-wide value.
packages/apps/kubernetes/templates/_helpers.tpl (1)

240-246: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider guarding against a non-list parameters value.

range .parameters | default list fails the render with a Go template error if a user sets parameters as a string or map. The schema declares an array, but the aggregated apiserver does not validate on write, per the comment at lines 213-221. A kindIs "slice" check would produce the same clear message as the other guards.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/apps/kubernetes/templates/_helpers.tpl` around lines 240 - 246,
Guard the parameters handling in the node-group validation before ranging over
`.parameters` by checking that it is a slice with `kindIs "slice"`. For
non-slice values, fail using the same clear invalid-kernelModules-parameter
message pattern as the existing guards; retain the current validation and append
behavior for valid lists.
api/apps/v1alpha1/kubernetes/zz_generated.deepcopy.go (1)

340-359: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Regenerate the deep-copy files from controller-gen source instead of editing them.

hack/update-codegen.sh is the repo generation entrypoint, and these api/apps/v1alpha1/kubernetes/zz_generated.deepcopy.go and api/apps/v1alpha1/kubernetesnodes/zz_generated.deepcopy.go files are controller-gen output. Avoid leaving manual edits in generated Go files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/apps/v1alpha1/kubernetes/zz_generated.deepcopy.go` around lines 340 -
359, Regenerate the DeepCopyInto and DeepCopy implementations using
hack/update-codegen.sh and the controller-gen source definitions instead of
manually editing generated output. Apply this to
api/apps/v1alpha1/kubernetes/zz_generated.deepcopy.go at lines 340-359 and
432-438, and api/apps/v1alpha1/kubernetesnodes/zz_generated.deepcopy.go at lines
68-74 and 120-139; commit only the resulting generated changes.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/apps/v1alpha1/kubernetes/types.go`:
- Around line 281-282: Remove omitempty from NodeGroup.KernelModules in
api/apps/v1alpha1/kubernetes/types.go (lines 281-282) and
ConfigSpec.KernelModules in api/apps/v1alpha1/kubernetesnodes/types.go (lines
51-52) so an explicit [] remains serialized as opt-out; then regenerate the
generated deepcopy outputs.

In `@packages/apps/kubernetes-nodes/README.md`:
- Line 45: Update the generator input for kernelModules so its three-state
behavior is explicit: unset/automatic selects chart defaults, while [] is an
explicit opt-out. Regenerate both generated tables:
packages/apps/kubernetes-nodes/README.md:45-45 and
packages/apps/kubernetes/README.md:126-126, ensuring neither presents [] as the
sole default; do not edit the generated READMEs manually.

In `@packages/apps/kubernetes/README.md`:
- Line 229: Keep the Phase 1 contract for talos.registryMirrors consistent
across packages/apps/kubernetes/README.md:229-229 and
packages/system/kubernetes-rd/cozyrds/kubernetes.yaml:35-35. Since per-tenant
registries.mirrors has no consumer before Phase 2, remove or mark
talos.registryMirrors unsupported in both the documentation and generated
schema, unless implementing the consumer and restoring the Phase 2 behavior is
intended.

In `@packages/system/kubernetes-nodes-rd/cozyrds/kubernetes-nodes.yaml`:
- Line 41: Add ["spec", "kernelModules"] to the keysOrder list in the Kubernetes
node configuration, placing it immediately after ["spec", "schematicID"] and
before ["spec", "kubelet"], while preserving the existing ordering.

---

Nitpick comments:
In `@api/apps/v1alpha1/kubernetes/zz_generated.deepcopy.go`:
- Around line 340-359: Regenerate the DeepCopyInto and DeepCopy implementations
using hack/update-codegen.sh and the controller-gen source definitions instead
of manually editing generated output. Apply this to
api/apps/v1alpha1/kubernetes/zz_generated.deepcopy.go at lines 340-359 and
432-438, and api/apps/v1alpha1/kubernetesnodes/zz_generated.deepcopy.go at lines
68-74 and 120-139; commit only the resulting generated changes.

In `@packages/apps/kubernetes-nodes/tests/schematic_per_pool_test.yaml`:
- Around line 103-115: Extend the schematic validation tests with a case that
sets the cluster-wide talos.schematicID to a shell-injection value while leaving
the per-pool override unset. Assert Helm rendering fails with the existing
“invalid schematicID” error through templates/talos-reconcile-job.yaml, proving
validation covers the effective cluster-wide value.

In `@packages/apps/kubernetes/templates/_helpers.tpl`:
- Around line 240-246: Guard the parameters handling in the node-group
validation before ranging over `.parameters` by checking that it is a slice with
`kindIs "slice"`. For non-slice values, fail using the same clear
invalid-kernelModules-parameter message pattern as the existing guards; retain
the current validation and append behavior for valid lists.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 29c0e880-7cd9-422a-b4db-08b830ff9f7d

📥 Commits

Reviewing files that changed from the base of the PR and between cfaf2e0 and 63f6485.

📒 Files selected for processing (25)
  • api/apps/v1alpha1/kubernetes/types.go
  • api/apps/v1alpha1/kubernetes/zz_generated.deepcopy.go
  • api/apps/v1alpha1/kubernetesnodes/types.go
  • api/apps/v1alpha1/kubernetesnodes/zz_generated.deepcopy.go
  • hack/talos-reconcile-heredoc_test.bats
  • packages/apps/kubernetes-nodes/README.md
  • packages/apps/kubernetes-nodes/templates/_helpers.tpl
  • packages/apps/kubernetes-nodes/templates/nodegroup.yaml
  • packages/apps/kubernetes-nodes/templates/talos-reconcile-job.yaml
  • packages/apps/kubernetes-nodes/tests/kernel_modules_test.yaml
  • packages/apps/kubernetes-nodes/tests/render-parity.sh
  • packages/apps/kubernetes-nodes/tests/schematic_per_pool_test.yaml
  • packages/apps/kubernetes-nodes/values.schema.json
  • packages/apps/kubernetes-nodes/values.yaml
  • packages/apps/kubernetes/README.md
  • packages/apps/kubernetes/templates/_helpers.tpl
  • packages/apps/kubernetes/templates/cluster.yaml
  • packages/apps/kubernetes/templates/talos/talos-reconcile-job.yaml
  • packages/apps/kubernetes/tests/kernel_modules_test.yaml
  • packages/apps/kubernetes/tests/schematic_per_nodegroup_test.yaml
  • packages/apps/kubernetes/tests/talos_templates_test.yaml
  • packages/apps/kubernetes/values.schema.json
  • packages/apps/kubernetes/values.yaml
  • packages/system/kubernetes-nodes-rd/cozyrds/kubernetes-nodes.yaml
  • packages/system/kubernetes-rd/cozyrds/kubernetes.yaml

Comment thread api/apps/v1alpha1/kubernetes/types.go Outdated
Comment thread packages/apps/kubernetes-nodes/README.md Outdated
Comment thread packages/apps/kubernetes/README.md Outdated
Comment thread packages/system/kubernetes-nodes-rd/cozyrds/kubernetes-nodes.yaml Outdated
@lexfrei

Copy link
Copy Markdown
Contributor

NOT LGTM. Two things, both new since the last round: the merge of main dropped a paragraph main owns, and the kernelModules field description states a property that stops being true the moment #3523 lands.

Everything I checked from the previous rounds still holds. The helm suites are green (223 in kubernetes, 31 in kubernetes-nodes), render-parity.sh reports GOLDEN PARITY on all seven cases, the heredoc suite passes all seven of its tests, and make generate in both packages plus controller-gen object for the api submodule produce no diff.

B1: the merge of main reverted the air-gapped bullet in packages/apps/kubernetes/README.md

The merge commit resolved README.md by keeping the branch side, so the paragraph #3575 rewrote is gone and the pre-#3575 text is back. Relative to its branch parent the merge added 2 lines to that file; relative to main it changed 39 and removed 35.

$ git show origin/main:packages/apps/kubernetes/README.md | grep -c 'talos.registryMirrors` maps an upstream registry host'
1
$ git show HEAD:packages/apps/kubernetes/README.md       | grep -c 'talos.registryMirrors` maps an upstream registry host'
0
$ git show origin/main:packages/apps/kubernetes/README.md | grep -c 'Phase 2 follow-up'
0
$ git show HEAD:packages/apps/kubernetes/README.md       | grep -c 'Phase 2 follow-up'
1

So the shipped README now tells operators that per-tenant registry mirrors "remain a Phase 2 follow-up, file an issue if you depend on this and it is not yet landed", while talos.registryMirrors is shipped, is in the parameter table two screens below, and is in the RD schema. The bot flagged the inconsistency at README:229 and proposed removing talos.registryMirrors from the docs and the schema; that is backwards, the prose is the stale half. Restore main's paragraph.

I checked whether anything else was lost in that merge and nothing was: the set of files where the merge result differs from main is exactly the set the PR declares it touches, and in packages/apps/kubernetes-nodes/README.md only generated table rows changed.

B2: the kernelModules description is falsified by #3523, and it ships in the CRD

The description says, in ten files including both cozyrds ResourceDefinitions:

Changing this on a node group that already has running workers does not reach them: the TalosConfigTemplate its MachineDeployment references has a fixed name, so rewriting the template leaves spec.template untouched, CAPI starts no rollout, and a running Machine keeps the machine config it booted with. Replace the group's Machines to apply it. Unlike schematicID, which changes the boot image and therefore rolls the group by itself.

That is true on today's main and false with #3523 in the tree. I merged the two branches, ported the two hunks into the shared _talosconfigtemplate.tpl, and read the name off MachineDeployment.spec.template.spec.bootstrap.configRef:

tree node group TalosConfigTemplate
#3523 only one nvidia.com/* GPU t-md0-52cee2
#3523 + this PR, kernelModules unset same t-md0-cc5ae0
#3523 + this PR, kernelModules: [] same t-md0-52cee2
#3523 only no GPU t-md0-35293f
#3523 + this PR no GPU t-md0-35293f

With both in a release, the automatic NVIDIA set rotates the content-hashed template name of every existing GPU node group with no operator action, and CAPI rolls those workers. kernelModules then rolls the group by itself, exactly like schematicID. Non-GPU groups are untouched, and [] reproduces the pre-change hash byte for byte, which is the opt-out working as designed.

The release note carries the #3515 caveat and says it disappears when the naming fix ships. The field description does not: it states the fixed-name mechanism as a permanent property of the system, and it is the text an operator reads in the dashboard long after the release note is history. Either drop the mechanism and say the change may not reach existing workers until the TalosConfigTemplate naming fix is released, or qualify it in place.

Recommendations

The release note says setting kernelModules on an existing node group has no effect before #3523. It does not say the automatic set fires unasked. On this head a nvidia.com/* group's reconcile Job name moves from t-talos-reconcile-md0-752f85 to t-talos-reconcile-md0-80ebc9, so an upgrade creates a fresh Job for every existing GPU node group and that Job applies a changed TalosConfigTemplate under the fixed name. The script runs under sh -ec and the apply carries no tolerance, so if the immutability webhook rejects it the way #3515 describes, the Job exits non-zero, retries to backoffLimit: 30 and ends Failed. I could not exercise the webhook here, so treat the rejection as read from #3515 rather than measured. One sentence in the release note covers it.

invalid schematicID "" names a rule the value does not break. --set talos.schematicID= renders on the merge base (producing image//v1.13.6/openstack-amd64.raw.xz, which 404s at the factory) and now fails the render with "must not contain $, a backtick, a backslash, quotes or whitespace". Refusing it is right; the message should say the value must also be non-empty.

treats an empty per-node-group schematic as unset asserts only the boot disk image. Its two sibling cases assert both consumers, and the whole point of routing both through one helper is that they cannot disagree. One more assert on the installer image closes it.

^[a-z0-9_-]+$ rejects an uppercase module name. Nothing in the kernel forbids one; the convention is lowercase and the guard is otherwise correct, so this is a note rather than a request.

Sequencing with #3523

The two branches conflict in four files, so whoever merges second resolves by hand, and the interesting part is what a plausible resolution does. I took #3523's side wherever it had rewritten a region, which is the obvious call since it moved the machine config out of the Job template. packages/apps/kubernetes-nodes/templates/nodegroup.yaml auto-merged and kept this PR's "schematicID" line, while the Job's own $group dict came from #3523's side and lost both new keys. The two sites then hash different specs:

MachineDeployment configRef -> kubernetes-myk8s-md0-4522a1
Job TCT_NAME                -> kubernetes-myk8s-md0-ffdbe1

That is the cannot create a new MachineSet when templates do not exist deadlock, produced by a resolution nobody would call careless. The good news is that this PR's own tests catch it: render-parity.sh fails on MachineDeployment and on MachineConfig(talos-reconcile), and both kubernetes-nodes suites go red. Worth knowing before the rebase rather than during it.

Two smaller integration details for that rebase. #3523's kubernetes.talosConfigTemplateSpec takes root and group but no groupName, which both helpers here need for their fail messages. And nodegroup.yaml's $group will need kernelModules added, because after #3523 that dict feeds the template hash rather than only the KubevirtMachineTemplate.

What I verified

No worker roll for anything that resolves to no modules: rendering the parent chart at the merge base and at this head, for the default node group and for an amd.com/gpu group, the only difference is the six per-render random Talos secret lines. The nvidia.com/* group differs by the Job hash and the eight-line kernel: block, which is the intended change.

The tests are not decorative. I broke each thing a test claims to guard and every one went red: reordering the module list in one chart's helper alone fails MachineConfig(talos-reconcile) on the gpu and schematic cases and nowhere else; dropping the schematic guard turns the new heredoc test red; kindIs "slice" replaced by truthiness, the module-name regex removed, and the rebuilt dict replaced by the raw item each fail exactly one case; removing the kernel: block or gating it on true fails five and three; nindent 28 moved to 26 or 30 fails five, so the absolute indentation really is pinned in both directions; routing the installer image or the boot disk URL around the helper fails five and four cases of the schematic suite. The machine-config comparison is also not a no-op: run against main's two unmodified charts it passes.

The three-state contract holds at the schema layer too. A bare kernelModules: is refused by Helm with at '/nodeGroups/md0/kernelModules': got null, want array, and [] is accepted, which is what the undefaulted field buys.

Every consumer of the schematic goes through the helper. There is no remaining .Values.talos.schematicID in a rendered position in either chart; the four call sites are the two boot disk URLs and the two installer images.

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.

Two blockers, both in the delta since the last approval rather than in the earlier work.

First, the merge commit resolved packages/apps/kubernetes/README.md in favour of the branch and dropped a paragraph that belongs to main. The shipped README now says per-tenant registry mirrors "remain a Phase 2 follow-up", while talos.registryMirrors landed in #3575 and is documented in the parameter table of that same README. Measured both ways: the sentence about mapping an upstream registry host is present on main and absent on this head, and "Phase 2 follow-up" is the reverse. Nothing else was lost in that merge.

Second, the description of kernelModules becomes false the moment #3523 lands, and it ships in the CRD, so it is what an operator reads in the dashboard. It states that the fixed TalosConfigTemplate name means CAPI starts no rollout and that machines must be replaced, unlike schematicID. With both changes in a release, the automatic NVIDIA set turns the content hash of every existing GPU node group, with no operator action, and CAPI rolls those workers. Measured by assembling both branches in one tree: the same group renders t-md0-52cee2 with 3523 alone and t-md0-cc5ae0 with both, returning to 52cee2 when kernelModules is set to an empty list. On today's main the text is true, which is why this is worth fixing now rather than after.

The branches conflict textually in four files, so nothing merges silently, but the obvious resolution in favour of 3523 drops both new keys from the Job dict while nodegroup.yaml keeps them, producing two different hashes. The suites of this PR catch that. Details in the comment above.

@lexfrei

Copy link
Copy Markdown
Contributor

Checked both PRs for a re-review pass today. The branches haven't moved since Aug 10, so the Aug 14 reviews stand: the hash-input duplication in #3523 and the README/values claims here are still open. This branch has also picked up merge conflicts with main in 6 files since then. Happy to re-review as soon as a new revision lands — flagging in case the review notifications got lost.

@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files and removed size/XXL This PR changes 1000+ lines, ignoring generated files labels Sep 3, 2026
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@mattia-eleuteri mattia-eleuteri changed the title feat(kubernetes): per-node-group kernel modules and Talos schematic feat(kubernetes): per-pool kernel modules and Talos schematic for worker nodes Sep 3, 2026

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.

mattia-eleuteri I started to rebase this branch onto current main and stopped before resolving anything, because main now has its own per-pool schematic and the two don't fit together. Nothing was pushed to the branch.

Main lets a pool pick its schematic since #4171. It is set through osImage.factory.schematicID or osImage.builtin.schematicID, and kubernetes-nodes.resolveOsImage resolves it once. Both the boot disk in nodegroup.yaml and the installer image in talos-reconcile-job.yaml read that result, which is the same pairing kubernetes.schematicID exists for in this PR.

A root-level schematicID on top of that gives one pool two knobs for the same value, with no defined precedence between them. The guards also disagree. Main refuses anything outside ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$, because the schematic now also goes into the golden DataVolume name talos-worker-<schematicID>-<version>. This PR accepts gpu/nvidia-open on purpose. hack/talos-reconcile-heredoc_test.bats on main already asserts main's refusal message, so that file conflicts too.

I think the schematic half should go: drop the schematicID field and the kubernetes.schematicID helper, and have the docs point a GPU pool at osImage.factory.schematicID. The kernelModules half still fits main. The $group dict in talos-reconcile-job.yaml and the nodeLabels/kubelet block it would sit between are still there, main just added osImage to the dict, so its conflicts are textual.

One point from my previous review is still open. The kernelModules description says the TalosConfigTemplate has a fixed name, so changing the field rolls nothing. That is true on main today, and #3523 is still open, so it stays true until that lands. Once both are in, the automatic NVIDIA set changes the template name of every GPU pool and CAPI rolls them. The README point is closed, packages/apps/kubernetes is no longer touched.

The last CI run on this head is from 2026-09-03 (pre-commit green). E2E never ran here, since the fork lane was broken for every fork PR until #4514, so there is no e2e signal either way.

mattia-eleuteri added a commit to mattia-eleuteri/cozystack that referenced this pull request Sep 29, 2026
Closes the gpu-operator half of cozystack#3563. Tenant clusters have run Talos
workers since 1.6.0, where the NVIDIA driver comes from a system extension
baked into the worker image rather than from the operator's driver
container. The addon default still described the Ubuntu shape, so a GPU
node group came up with the operator fighting the OS for the driver and no
path at all for CUDA libraries to reach a tenant pod.

Three things change, each fixing one silent failure:

driver.enabled and toolkit.enabled go false. The driver container would
load its own module and clash with the extension's; the toolkit container
would rewrite /etc/containerd/config.toml, which Talos owns and which
already registers the nvidia runtime with enable_cdi = true and
cdi_spec_dirs = ['/run/cdi'].

validator.driver.env points DRIVER_ROOT and NVIDIA_DRIVER_ROOT at / and
disables /dev/char symlink creation. The driver validator otherwise looks
for a driver container install, finds none, never writes its sentinel, and
leaves the device plugin blocked behind toolkit-validation — the loop on
"Attempting to validate a driver container installation".

devicePlugin.env selects cdi-annotations and points NVIDIA_CTK_PATH at the
host's nvidia-ctk. Tenant pods carry no runtimeClassName and did not need
one on Ubuntu, where the toolkit made nvidia the default containerd
runtime; Talos does not, so CDI is the only route for the CUDA libraries.
DEVICE_ID_STRATEGY, NVIDIA_DRIVER_ROOT and CDI_ANNOTATION_PREFIX are set
and tested alongside it: CDI device names key on the UUID strategy, the
plugin resolves the driver from NVIDIA_DRIVER_ROOT, and the annotation
prefix has to be the one containerd reads, so a regression in any of them
breaks device resolution on its own.

The previous default fed NVreg_NvLinkDisable=1 to the driver container
through the nvidia-kernel-module-params ConfigMap. With that container off
the ConfigMap is mounted by nothing, so the default is removed. On Talos a
module parameter belongs to the worker machine config, which the
kernelModules field of the KubernetesNodes app
(packages/apps/kubernetes-nodes, cozystack#3571) writes. The wrapper chart keeps
the ConfigMap for an operator who re-enables the driver container.

Every value here is the configuration validated on a production Talos GPU
node group, minus the parts that only mattered while the driver container
was in play.

Not covered: HAMi. When addons.hami.enabled the operator's device plugin
stays off and HAMi serves the GPUs, and HAMi's own plugin needs equivalent
CDI wiring on Talos that this default does not attempt.

Refs: cozystack#3563

Assisted-by: LLM
Signed-off-by: Mattia Eleuteri <[email protected]>
A Talos system extension installs a kernel module but does not load it;
loading is machine.kernel.modules' job. The worker machine config had no
such block and no values key could add one, so a GPU pool on Talos
workers was unusable as shipped: the extension supplied the NVIDIA
module, nothing loaded it, and the failure was silent end to end. The VM
held the PCI device, the node advertised no GPU, and no component logged
an error.

The field is three-state, and the states are distinguishable because it
carries no default: absent lets the chart choose (a pool holding at least
one nvidia.com/* GPU gets nvidia, nvidia_uvm, nvidia_drm, nvidia_modeset,
in that order, since Talos loads the list in sequence and the last three
depend on the first; any other pool gets nothing), a non-empty list
replaces that choice outright, and an explicit [] opts out even on a GPU
pool. A default of [] would collapse absent into opted-out for every pool
and make the automatic set unreachable.

The automatic nvidia entry carries NVreg_NvLinkDisable=1. Individual
GPUs are passed into worker VMs without their NVSwitches, and on
non-Talos workers the gpu-operator driver container applies that
parameter; on Talos that container has to be off, so nothing else would.

Names and parameters reach the reconcile Job's unquoted heredoc, so they
are validated at render and the emitted list is rebuilt from those two
fields alone: the schema does not prune unknown keys, and any other key
passed through toYaml would reach the shell unchecked.

A pool that resolves to no modules renders the machine config it
rendered before, byte for byte, so its content-hash Job name does not
rotate.

Refs: cozystack#3563
Assisted-by: LLM
Signed-off-by: Mattia Eleuteri <[email protected]>
@mattia-eleuteri
mattia-eleuteri force-pushed the feature/kubernetes-nodegroup-kernel-modules branch from c749681 to f7c04b7 Compare September 29, 2026 15:12
@mattia-eleuteri mattia-eleuteri changed the title feat(kubernetes): per-pool kernel modules and Talos schematic for worker nodes feat(kubernetes): per-pool kernel modules for Talos worker nodes Sep 29, 2026
@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

Aleksei Sviridkin (@lexfrei) Agreed on both points. The branch is rebased onto current main as a single commit, and the schematic half is gone: no root-level schematicID, no kubernetes.schematicID helper, no schematic_per_pool_test.yaml, and hack/talos-reconcile-heredoc_test.bats keeps main's refusal of a hostile talos.schematicID untouched. The kernelModules description and the release note point a GPU pool at osImage.factory.schematicID.

What is left is kernelModules in the $group dict next to osImage, the kernel: block between nodeLabels and kubelet, and the helper, renamed kubernetes-nodes.kernelModules since there is no parent-chart copy to keep in step with any more. Stale references to tests/render-parity.sh and to the parent chart are gone from the helper and the test header. The heredoc bats file now lists kernelModules in its validated arm, with a refusal case for a hostile name and parameter and an execution case showing a semicolon parameter surviving the real shell.

On the open point: the description no longer states the fixed name as a property of the field. It says how a change reaches an existing pool depends on the TalosConfigTemplate naming. Today the apply is refused (#3515), so running workers keep their config and new workers boot from the old template. Once the template is content-named (#3523), the change rolls the pool. It also says the automatic set is itself such a change for every existing nvidia.com/* pool that leaves the field unset. The release note and the PR body spell out the consequence you describe: with both in, those GPU pools are rolled on upgrade.

helm unittest packages/apps/kubernetes-nodes passes (31 suites, 172 tests, 12 snapshots, render_snapshot_test.yaml unmodified), as does hack/cozytest.sh hack/talos-reconcile-heredoc_test.bats (8 cases). The PR body is updated to match.

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.

mattia-eleuteri NOT LGTM. Two things are left.

The parameter guard lets Unicode line separators through. RE2's \s and [:cntrl:] are ASCII-only, so U+2028 and U+2029 pass ^[^[:cntrl:]\s$\x60\\'\"]+$ in _helpers.tpl. toYaml then writes the character raw inside a single-quoted scalar. Helm's YAML parser treats it as a line break, so it ends the Job's command block scalar early. With parameters: ["a=1
EOF
id>/tmp/x"] the render fails with yaml: line 159: could not find expected ':' instead of the kernelModules error. I could not turn this into a manifest that parses with injected keys, so today it only breaks the tenant's own release with a confusing message. Still, this guard is the last gate before the heredoc, and right now it holds only because the rest of the manifest happens to be unparseable after the break.

An ASCII check next to the existing one closes it:

if not (and (regexMatch `^[[:graph:]]+$` $param) (regexMatch `^[^$\x60\\'\"]+$` $param))

I tried it locally. Both separators are refused with the kernelModules message, the semicolon parameter still renders, and the unit tests and the heredoc bats stay green. A unittest case with U+2028 would pin it.

The PR body carries the branch history into the merge commit. The "Rebased onto main and reduced, 2026-09-29" quote block describes a field this PR no longer has, and the downstream section says the follow-ups "narrow with this reduction". The body becomes the merge commit message, so please keep only what the PR ships.

Checked and fine: the default render is byte-identical to the merge base, an nvidia.com/* pool gets the four modules with only the Job hash rotating, and [] opts out. Mutating the name regex, the parameter regex, the rebuild, the kindIs check, the NvLink parameter, the module order, the prefix match or the $group key each turns a test red. Generated files match cozyvalues-gen v1.7.0, and the field path matches what #3585 points at.

RE2's \s and [:cntrl:] only cover ASCII, so the parameter deny class
passed U+2028 and U+2029. toYaml writes either one raw, and Helm's YAML
parser reads it as a line break that ends the reconcile Job's command
block scalar early. The render then failed on an unparseable manifest
rather than on the guard, which is the last check before the unquoted
heredoc.

Pinning parameters to printable ASCII closes every non-ASCII line break
at once, and the deny class keeps refusing what the heredoc expands.

Assisted-by: LLM
Signed-off-by: Mattia Eleuteri <[email protected]>
@mattia-eleuteri

Copy link
Copy Markdown
Collaborator Author

Aleksei Sviridkin (@lexfrei) both points are handled in 91c87b8.

Parameter guard: it is now the two checks you proposed, ^[[:graph:]]+$ and ^[^$\x60\\'\"]+$, and the error message says "printable ASCII with no whitespace" instead of listing whitespace and control characters. kernel_modules_test.yaml gains one case for U+2028 and one for U+2029, and both assert the kernelModules message, not just any failure. Against the old regex they failed with yaml: line 287: could not find expected ':', so they pin the guard itself rather than the manifest failing to parse. helm unittest packages/apps/kubernetes-nodes: 174 passed. make bats-unit-talos-reconcile-heredoc_test: 8 passed, the semicolon parameter included.

PR body: the "Rebased onto main and reduced" block is gone, and so is the "narrow with this reduction" wording in the downstream section. The body now describes only what the PR ships, and the parameter guard paragraph and the test counts match the new commit.

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.

mattia-eleuteri LGTM. With the old regex put back, both new cases fail, so the guard is pinned. The body is fixed too.

Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Sep 29, 2026
…los (#3585)

<!-- Thank you for making a contribution! Here are some tips for you:
- Use Conventional Commits for the PR title: `type(scope): description`
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Scopes are not an exhaustive list — pick the most specific scope for
the change and extend the list when a genuinely new area appears.
Examples:
- System components: dashboard, platform, operator, cilium, kube-ovn,
linstor, fluxcd, cluster-api
- Managed apps: postgres, mariadb, redis, kafka, clickhouse,
virtual-machine, kubernetes
- Development and maintenance: api, hack, tests, ci, docs, maintenance
- Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or
add a `BREAKING CHANGE:` footer
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does

Closes the gpu-operator half of #3563 — gap 3 (the operator cannot
validate a driver it did not install) and gap 4 (nothing routes CUDA
libraries to a tenant pod).

Tenant clusters have run Talos workers since 1.6.0, where the NVIDIA
driver comes from a system extension baked into the worker image rather
than from the operator's driver container. The addon default still
described the Ubuntu shape, so a GPU node group came up with the
operator fighting the OS for the driver and no path at all for the CUDA
libraries.

Three changes, each closing one silent failure:

| Change | Without it |
|---|---|
| `driver.enabled: false`, `toolkit.enabled: false` | the driver
container loads its own module and clashes with the extension's; the
toolkit container rewrites `/etc/containerd/config.toml`, which Talos
owns and which already registers the `nvidia` runtime with `enable_cdi =
true` and `cdi_spec_dirs = ['/run/cdi']` |
| `validator.driver.env`: `DRIVER_ROOT=/`, `NVIDIA_DRIVER_ROOT=/`,
`DISABLE_DEV_CHAR_SYMLINK_CREATION=true` | the driver validator looks
for a driver-container install, finds none, never writes its sentinel,
and the device plugin stays blocked behind `toolkit-validation` — the
loop on `Attempting to validate a driver container installation` (**gap
3**) |
| `devicePlugin.env`: `DEVICE_LIST_STRATEGY=cdi-annotations`,
`NVIDIA_CTK_PATH=/usr/local/bin/nvidia-ctk`, plus `DEVICE_ID_STRATEGY`,
`NVIDIA_DRIVER_ROOT`, `CDI_ANNOTATION_PREFIX` | tenant pods carry no
`runtimeClassName` and did not need one on Ubuntu, where the toolkit
made `nvidia` the default containerd runtime. Talos does not, so CDI is
the only route: the pod gets the device and no CUDA libraries (**gap
4**) |

Every value is the configuration validated on a production Talos GPU
node group, minus the parts that only mattered while the driver
container was in play. No DaemonSet, no new image, no new package — the
fix is values.

### The removed default

The previous default fed `NVreg_NvLinkDisable=1` to the driver container
through the `nvidia-kernel-module-params` ConfigMap. With that container
off the ConfigMap is mounted by nothing, so this removes the default. On
Talos the parameter belongs to the worker machine config, which the
`kernelModules` field of the worker pool writes: that field is top-level
in the KubernetesNodes app (`packages/apps/kubernetes-nodes`), added by
#3571, and its automatic set for a pool holding an `nvidia.com/*` GPU
already loads `nvidia` with `NVreg_NvLinkDisable=1`. The wrapper chart
keeps the ConfigMap feature for an operator who re-enables the driver
container through `valuesOverride`. A test pins the absence so the dead
default cannot come back and offer a second, inert source for the same
setting.

### Relationship to #3571

Independent files, no conflict, but only useful together: this makes
`driver.enabled: false` correct, and #3571 is what actually loads the
modules the extension ships. Merged alone, this changes a GPU node group
from "operator fights the OS" to "operator stays out of the way and the
modules still are not loaded" — better, but not yet working. #3571 alone
leaves the operator fighting for the driver. Either order is safe; both
are needed for a GPU node group to work.

### What is verified, and what is not

Verified here: the rendered HelmRelease values, that `valuesOverride`
still wins over each new key, and that the removed default is gone. The
individual values are the ones running in production today, applied by
hand through `addons.gpuOperator.valuesOverride`.

Not verified here: this exact default exercised end-to-end as the
chart's own output. That needs a Talos GPU node group whose modules
load, which needs #3571 released. I would rather say so than imply a
green e2e that does not exist — happy to hold this as draft until I have
run a single-node L40S cluster against the chart default, and I will
report the result on this PR either way.

### Not covered

HAMi. When `addons.hami.enabled` the operator's device plugin stays off
and HAMi serves the GPUs; HAMi's own plugin needs equivalent CDI wiring
on Talos, which this default does not attempt. Flagging rather than
guessing — the production cluster this was derived from runs HAMi
disabled, so I have no validated configuration for that path. Worth its
own issue if a maintainer confirms the shape.

Also not in scope, and unchanged from the merge base:
`talos.installerRepository` and `talos.version` reach the worker machine
config unvalidated (being tracked separately by @lexfrei), and the
`nvidia-operator-validator` chain for the *management* cluster's own
passthrough/vgpu variants, which never load the host driver at all.

### Screenshots

No UI changes.

### Screenshots

<!-- REQUIRED for UI changes: attach screenshots or screen recordings
demonstrating
the visual impact of your changes. PRs with UI changes without
screenshots will not be merged. -->

### Downstream repositories

<!--
Cozystack is upstream for repositories that are NOT kept in sync with it
automatically, and nothing in CI compares the two sides, so a change
here can break them silently.

Which change forces what, and which file to touch, is the trigger map in
docs/agents/contributing.md under "Downstream Repositories". Walk it
before you tick anything.

IF YOU ARE AN AI AGENT OR AN LLM, THIS IS PART OF THE TASK, NOT A FORM
TO FILL IN:

1. Walk the trigger map against the actual diff of this PR, file by
file. Do not decide from the PR title or from your memory of what you
changed.
2. For every repository you tick, open the follow-up PR there and link
it on the same line. A ticked box with no link claims work that does not
exist, which is worse than an empty box. Search that repository for an
open PR or issue covering it first, and link that instead of filing a
duplicate.
3. When the follow-up is out of scope, or needs a decision that is not
yours to make, open an issue in that repository instead, link that, and
say so in the PR body. Do not push a speculative PR into another
repository just to fill a line here.
4. Do not tick "No downstream repository is affected" to make the
checklist go away. If you are unsure, leave every box empty and say so
in the PR body, so a human decides.
-->

- [ ] No downstream repository is affected by this change
- [x] [cozystack/website](https://github.com/cozystack/website) -
follow-up: cozystack/website#643
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:

### Release note

<!--  Write a release note:
- Explain what has changed internally and for users.
- Start with the same `type(scope):` prefix as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->

```release-note
feat(kubernetes): The GPU Operator addon of tenant Kubernetes clusters now defaults to a driver provided by the operating system, which is the shape of the Talos workers these clusters have run since 1.6.0. `driver` and `toolkit` are disabled (the driver comes from a Talos system extension, and containerd configuration belongs to the machine config), the driver validator is pointed at the host root so it stops looping on "Attempting to validate a driver container installation", and the device plugin is switched to CDI (`DEVICE_LIST_STRATEGY=cdi-annotations`) so tenant pods receive the CUDA libraries — previously they received the GPU device and nothing else, since Talos does not make `nvidia` the default containerd runtime. The previous default, which fed `NVreg_NvLinkDisable=1` to the operator's driver container through a ConfigMap, is removed because that container is disabled; on Talos the parameter is carried by the worker machine config, through the `kernelModules` field of the worker pool in the KubernetesNodes app (`packages/apps/kubernetes-nodes`), which loads `nvidia` with `NVreg_NvLinkDisable=1` by default on a pool holding an `nvidia.com/*` GPU. Operators who overrode `addons.gpuOperator.valuesOverride` to work around this by hand can drop those overrides. NOTE: an override that sets `devicePlugin.env` replaces the list rather than merging it, so restate the full list when overriding it or CDI stops working. HAMi is not covered: when `addons.hami.enabled` the operator's device plugin stays off and HAMi's own plugin needs equivalent CDI wiring, which this change does not attempt.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Configuration**
* GPU workloads on Talos workers now use host-provided NVIDIA drivers
and tooling by default; the GPU Operator’s driver and toolkit are
disabled.
* Device discovery uses CDI annotations and UUID-based device IDs. When
HAMi is enabled, the Operator’s device plugin is disabled.
  * Host-root validation no longer creates `/dev/char` symlinks.
  * Driver kernel-module configuration is no longer created by default.

* **Documentation**
* Clarified when kernel-module configuration applies and what settings
are needed to re-enable the driver.
  * Noted that `valuesOverride` replaces lists wholesale.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@lexfrei
Aleksei Sviridkin (lexfrei) merged commit 3e4030d into cozystack:main Sep 29, 2026
57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/kubernetes Issues or PRs related to the tenant Kubernetes app kind/feature Categorizes issue or PR as related to a new feature size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants