Skip to content

feat(kubernetes): boot tenant worker disks from a shared golden Talos image (CDI clone) - #3294

Merged
myasnikovdaniil merged 12 commits into
test/drop-ghcr-mirrorfrom
feat/kubernetes-golden-worker-image
Sep 7, 2026
Merged

myasnikovdaniil merged 12 commits into
test/drop-ghcr-mirrorfrom
feat/kubernetes-golden-worker-image

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Tenant worker boots from talos OS disk image that CDI streams over HTTP from image factory, and it does that once per worker. Same bytes N times, and N chances for a fetch to fail. In closed or rate-limited environment that is wrong shape.

This PR adds opt-in catalog and per-pool selector that reads from it.

packages/system/kubernetes-worker-image imports one golden DataVolume into cozy-public per (schematicID, version). Disabled by default, enable by adding cozystack.kubernetes-worker-image to bundles.enabledPackages. Its knobs (imageFactoryURL, storageClass, storage, images) are set through spec.components.platform.values.kubernetesWorkerImage on cozystack-platform Package. That needed optional package helper to learn components argument, which it did not have, so every optional package registered through it could name variant and nothing else. Nine other packages get same path for free.

KubernetesNodes gets osImage field with two mutually exclusive forms:

  • osImage.builtin boots pool workers by CDI-cloning golden from catalog. Clone is storage layer CSI copy, so OS image never crosses pod network and factory answers once for whole fleet instead of once per worker.
  • osImage.factory keeps HTTP import and lets pool pin own imageFactoryURL, schematicID and version. This is bring-your-own path for operator running own factory or caching mirror, and it is what pool gets when osImage is omitted.

In-guest talos installer written by reconcile job follows same resolution, so pool that pins release boots it and upgrades into it instead of being flipped back to cluster default halfway.

This is pool half of what #3950 asks for. That issue wants platform level defaults for two channels, worker OS image and in-guest registry mirrors. Here image channel gets per-pool source and shared cache, platform default plumbing is left to the issue.

Removed e2e workaround

Suites used to deploy talos-image-cache Deployment and point tenant CRs at it, on theory that public factory stalling mid-transfer is what makes workers miss node-join deadline. Work on #3513 settled it differently, what separates run that joins from run that stalls is host kvm_amd vgif flag, and stall reproduces on runs where cache was up and serving. So cache, its manifest, helper and test suite are gone and suites clone golden instead. That also puts clone path under e2e, unit tests alone never reached it.

Stacked on #4007 which removes other half of same band-aid on same evidence. Base of this PR points at that branch, so diff here is only this feature. Merge #4007 first.

Existing workers do not roll

With osImage omitted rendered KubevirtMachineTemplate is byte identical to what chart produced before. KMT is named by hash of its own content, so any drift would re-image every worker VM in fleet on next upgrade. Stored render snapshot is untouched by this PR, that is the evidence and not the claim.

What fails at render

Clone that would silently degrade into something slower or wrong is refused instead of rendered. Golden absent, or its import in terminal Failed phase. Pool storageClass different from golden one, because CDI cant CSI-clone across storage classes and falls back to host assisted copy over pod network, which is exactly the transfer osImage.builtin removes. Empty class on either side resolves against cluster default class before comparing, so pool at storageClass: "" is checked too. diskSize below golden storage, because clone target cannot be smaller than source. Talos release the matrix lists as unsupported, whether pool reached it through talos.version or through osImage.* override. Matrix passes Talos minor it does not list, same as before, so what it promises is that known-bad pairing is refused, not that every pairing is known.

Golden that is only mid-import is not an error. CDI holds worker disk until source populates, and failing there would abort whole pool HelmRelease for length of a download. Same reasoning now covers pool that already cloned: absent or Failed golden fails render only for pool still waiting for its first disk, otherwise reconcile of healthy pool would abort over source it no longer reads.

Catalog side refuses duplicate (schematicID, version) entries, entries whose schematic or version cannot produce name pool can reconstruct, and edit to storage, storageClass or source URL of entry whose golden already exists. Last one is not patchable in place, so without guard it lands as rejected patch on next upgrade.

Golden that declares no StorageClass is read from its PVC, not from whatever default is in force today, because those differ once cluster default moves. Cluster with two default classes resolves same way Kubernetes does, newest wins. Pool that already cloned is identified by annotation naming exactly that pool, not by disk name prefix, because pool md0-gpu produces disks carrying md0 prefix.

Three drift guards live in hack/check-worker-image-catalog-defaults.bats, because values that have to agree sit in files nobody edits together. One-sided talos bump trips all three.

Testing

  • helm unittest: kubernetes-nodes 117, kubernetes 145, kubernetes-worker-image 14, core/platform 143, render snapshot unchanged
  • full make unit-tests: 82 charts, 1742 helm-unittest cases, 1485 bats cases, exit 0
  • mutation pass over every guard term this diff adds: each term deleted in turn, suite required to notice. Four terms are not caught by helm unittest, and the count an earlier revision of this description carried was wrong. One is the second support-matrix call, pinned instead by the greps in hack/check-worker-image-catalog-defaults.bats:96-110. The other three were reported by IvanHunters and re-confirmed here by re-running his mutations: dropping the written kubernetes-worker-image.cozystack.io/pool annotation leaves the suite green (only the reader is asserted), replacing the legacy storageclass.beta.kubernetes.io/is-default-class disjunct with false leaves it green, and forcing the if $goldenStorage presence term open leaves it green while forcing it shut reddens two cases. All three are test-coverage gaps rather than live defects, and they are deliberately left open: this round closes only the two findings that change behaviour
  • go build and go vet on root and api/apps/v1alpha1
  • root make generate and pre-commit clean

Verified on a live cluster

The clone path was exercised end to end on a throwaway three-node Talos stand (OCI, Talos v1.13.6, LINSTOR/ZFS, replicated StorageClass) installed from this branch's packages/ tree. This covers what neither helm template nor the unit suites can answer.

  • The catalog wires up from bundles.enabledPackages alone: PackageSource cozystack.kubernetes-worker-image reconciled, its HelmRelease installed, and the golden talos-worker-<schematic>-v1.13.6 imported to Succeeded in 108s (6Gi, RWX, replicated).
  • A pool on osImage.builtin clones it on the storage layer. Both worker DataVolumes went CSICloneInProgress then Succeeded in 43s with Running=False reason=Completed message="Clone Complete", carrying source.pvc and no source.http, and no importer or upload Pod appeared in the tenant namespace.
  • The clone yields a bootable Talos root disk. Both worker VMs reached Running/Ready, joined the tenant cluster and went Ready about 66s after creation, and the tenant's cilium, coredns, csi, ingress-nginx and metrics-server addons all installed on them.
  • Access modes match across the hop: golden ReadWriteMany 6Gi to worker ReadWriteMany 20Gi on the same replicated class, grown by CDI.
  • The written annotations are correct in practice: each worker disk carries kubernetes-worker-image.cozystack.io/golden naming the golden and .../pool naming kubernetes-wimg-md0.
  • helm-controller converges on a builtin pool: forcing a reconcile left the content-hashed KubevirtMachineTemplate name unchanged and the VMI and DataVolume UIDs identical, so a reconcile does not roll the pool.
  • Cross-namespace clone RBAC is in place as shipped: create datavolumes --subresource=source -n cozy-public resolves to yes for a tenant ServiceAccount through the cdi-clone-dv RoleBinding, and CDI runs with cloneStrategyOverride: csi-clone.

Both behavioural findings were reproduced on the same stand. A KubernetesNodes carrying talos.version: v1.13 was admitted by the API with the value intact and its HelmRelease went InstallFailed naming the format; a subsequent unrelated edit (maxReplicas 1 to 3) was accepted by the CR and reached .spec.values of the HelmRelease while it stayed InstallFailed, so the edit is taken everywhere and applied nowhere. And a pool on osImage.builtin with talos.imageFactoryURL: "" -- a value the API preserves rather than defaulting away -- renders and clones normally with the guard scoped, which is the fix in this round.

Screenshots

Not applicable, no UI change.

Downstream repositories

Release note

feat(kubernetes): worker pool can boot from shared golden talos image instead of importing OS image over HTTP per worker. Enable optional `cozystack.kubernetes-worker-image` package to seed one golden DataVolume per (schematicID, version) in `cozy-public`, configure it through `kubernetesWorkerImage` in cozystack-platform values, then set `osImage.builtin` on `KubernetesNodes` pool to clone it. `osImage.factory` keeps HTTP path and adds per-pool overrides for `imageFactoryURL`, `schematicID` and `version`. Pools that leave `osImage` unset render as before and their workers do not roll.

The render also refuses a `talos.schematicID`, `talos.version` or `imageFactoryURL` -- and their `osImage.*` overrides -- whose shape cannot produce a working image reference: a plain lowercase alphanumeric schematic, a full `vMAJOR.MINOR.PATCH` release, and a plain `http(s)` URL. Every refused shape already produced an unbootable worker before this change, but it rendered, so a pool holding one moves from Ready-with-no-workers to a failed HelmRelease. The values schema types all three as bare strings, so the rejection lands on the HelmRelease rather than on the CR: the CR is admitted with the bad value, and later edits to unrelated fields on that pool are accepted and reach the HelmRelease values without being applied. `imageFactoryURL` is checked only on the paths that build a source URL from it, so a pool on `osImage.builtin` may leave it blank.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request optimizes the provisioning of tenant Kubernetes worker VMs by shifting from a per-worker HTTP image import pattern to a shared golden image cloning strategy. By leveraging CDI CSI-cloning, the system reduces network overhead and improves the reliability of node-join operations. The changes are designed to be non-disruptive, ensuring that existing clusters continue to function with their current configuration while new node groups automatically benefit from the improved cloning mechanism.

Highlights

  • Golden Image Optimization: Introduced a new system package kubernetes-worker-image to pre-seed Talos OS images as DataVolumes in cozy-public, enabling CDI CSI-cloning for tenant worker nodes.
  • Performance and Reliability: Replaced per-worker HTTP imports with storage-layer clones to eliminate node-join flakiness caused by Image Factory network contention and reduce dependency on external image streaming.
  • Backward Compatibility: Implemented logic to ensure existing node groups remain unchanged by pinning them to their current source type, preventing unnecessary worker rolls during upgrades.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@github-actions github-actions Bot added area/kubernetes Issues or PRs related to the tenant Kubernetes app kind/feature Categorizes issue or PR as related to a new feature labels Jul 14, 2026
@dosubot dosubot Bot added the area/storage Issues or PRs related to storage (linstor, seaweedfs, bucket, velero, harbor) label Jul 14, 2026

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces the kubernetes-worker-image package to pre-populate golden Talos worker OS images as DataVolumes in the cozy-public namespace. This enables tenant Kubernetes worker VMs to boot via CDI storage-layer clones instead of downloading raw images over HTTP, resolving node-join flakes and eliminating external dependencies during worker provisioning. The kubernetes app is updated to utilize these clones while maintaining backward compatibility for existing node groups. Feedback on the changes identifies that the fallback storage size in dv.yaml is set to 4Gi, which is smaller than the ~4.15 GiB Talos raw image and would cause failures. It is recommended to increase this fallback to 6Gi and quote the rendered value.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

storage:
resources:
requests:
storage: {{ .storage | default $.Values.storage | default "4Gi" }}

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.

medium

The fallback default storage size is set to 4Gi here. However, as noted in the PR description, the Talos raw image is ~4.15 GiB virtual, meaning a 4Gi PVC will fail with DataVolume too small. We should update this fallback default to 6Gi to match the default in values.yaml and prevent failures if $.Values.storage is ever omitted or cleared. Additionally, quoting the value ensures it is always rendered as a valid Kubernetes quantity string.

        storage: {{ .storage | default $.Values.storage | default "6Gi" | quote }}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — the fallback default is now 6Gi and quoted: {{ .storage | default $.Values.storage | default "6Gi" | quote }}. That matches the 6Gi in values.yaml and the documented ~4.15 GiB minimum of the Talos v1.13 openstack raw, so a cleared $.Values.storage can no longer render a too-small PVC. Fixed in d8ce521.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds a packaged golden Talos worker image catalog, supports per-node-group clone or HTTP image sources, updates installer and rendering tests, and removes the prior Talos image-cache e2e integration.

Changes

Golden Talos worker image provisioning

Layer / File(s) Summary
Worker image contracts and package wiring
api/apps/v1alpha1/kubernetes/*, packages/apps/kubernetes/values*, packages/core/platform/..., packages/system/kubernetes-worker-image/*
Adds worker image source types, schema documentation, package metadata, bundle wiring, catalog values, and generated schema documentation.
Golden DataVolume rendering
packages/system/kubernetes-worker-image/*
Renders deterministic Talos golden DataVolumes and tests default, override, storage, and multi-image behavior.
Per-node-group disk and installer selection
packages/apps/kubernetes/templates/..., packages/apps/kubernetes/tests/*
Selects CDI clones or factory HTTP imports for worker disks, resolves per-group installer images, and validates success and failure cases.
Talos image-cache e2e cleanup
hack/e2e-chainsaw/..., hack/e2e-install-cozystack.bats, hack/e2e-prepare-cluster.bats
Replaces cache-based setup with golden-image readiness checks, updates diagnostics and fixtures, enables the worker-image package, and increases VM disk capacity.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant KubernetesTemplate
  participant CozyPublic
  participant CDI
  participant WorkerVM
  KubernetesTemplate->>CozyPublic: check golden Talos DataVolume
  alt Builtin source selected
    KubernetesTemplate->>CDI: configure PVC clone source
    CDI->>WorkerVM: provision cloned boot disk
  else Factory source selected
    KubernetesTemplate->>CDI: configure HTTP import source
    CDI->>WorkerVM: provision imported boot disk
  end
Loading

Possibly related PRs

Suggested labels: kind/api-change, area/testing, area/virtualization

Suggested reviewers: kvaps

🚥 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.
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 main change: booting tenant worker disks from a shared golden Talos image through CDI cloning.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/kubernetes-golden-worker-image

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 and removed size/L This PR changes 100-499 lines, ignoring generated files labels Jul 14, 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 — the golden-image package is wired opt-in (off by default), so neither the production import reduction nor the e2e node-join flake fix this PR is premised on actually happens; the image-cache workaround is removed in the same change, leaving e2e more exposed than before.

Business context: Replace per-worker HTTP import of the Talos OS image from the public Image Factory with a per-worker CDI clone of a shared golden image in cozy-public, to remove the per-worker Factory dependency and the kubernetes-* e2e node-join flake (#3231).

Blockers

B1: The golden package is opt-in (default OFF), so the feature is inert by default and the description/release-note are inverted

File: packages/core/platform/templates/bundles/iaas.yaml:123

Issue: kubernetes-worker-image is registered via cozystack.platform.package.optional.default. That helper emits the Package only when the name is in bundles.enabledPackages (_helpers.tpl: if and (has $name $enabled) (not (has $name $disabled))). It is opt-in / disabled-by-default, not opt-out.

Evidence: the optional helper body gates on has $name $enabled. The sibling vm-default-images uses the same helper and its own changelog (v1.3.4 / v1.4.1) documents it as "disabled by default … enable via bundles.enabledPackages". No default enabledPackages in packages/core/platform/values.yaml lists either package. So on a stock install the golden DataVolume is never created and every worker takes the HTTP-import fallback.

Impact: the production benefit ("one import per image instead of one per worker") does not occur out of the box. The release note ("New node groups adopt the clone automatically") is false by default — with no golden, nothing clones. The description's "Opt-out via bundles.disabledPackages" is inverted.

Fix: decide the intent. If the golden should be active by default, use cozystack.platform.package.default (always-on). If opt-in is intended (a 6Gi replicated volume on every install, including VM-only / app-only clusters, is a real cost — the reason vm-default-images is opt-in), correct the description and release note to "opt-in via enabledPackages" and address B2.

B2: e2e never enables the golden, and the image-cache it removes was the only #3231 mitigation there

File: hack/e2e-install-cozystack.bats:127

Issue: the e2e installer sets bundles.enabledPackages: [cozystack.external-dns-application] only — kubernetes-worker-image is absent. Combined with B1, the golden is not deployed in e2e, so kubernetes-latest / kubernetes-previous workers hit $useClone=false and HTTP-import from the default https://factory.talos.dev. This PR also removes the in-sandbox talos-image-cache mirror that buffered that public endpoint.

Evidence: run-kubernetes.sh no longer injects talos.imageFactoryURL; no chainsaw fixture sets it; the new diagnostics comment itself says "a cluster that fell back to the HTTP import instead shows an importer-* pod looping on a factory error". The green E2E run is not proof — #3231 is by definition an intermittent factory stall, so one pass is consistent with "the factory happened to be reachable".

Impact: the PR's headline justification (fix the e2e flake) is not delivered, and e2e reliability likely regresses versus main (buffer removed, nothing replacing it).

Fix: add cozystack.kubernetes-worker-image to the e2e enabledPackages (the default golden schematicID/version already match packages/apps/kubernetes talos ce4c98… / v1.13.6, so the clone would be selected), and confirm a worker-spinning suite actually takes the clone path before removing the cache.

B3: New clone-selection branching ships with zero unit coverage in a chart that already mocks lookup

File: packages/apps/kubernetes/templates/cluster.yaml:110-127

Issue: the live-KMT source-type pin (http stays http, pvc stays pvc) and the new-group clone-adoption are the safety-critical "no worker roll" guarantee, yet no unit test exercises any of the new branches — the existing tests only cover the lookup-nil HTTP path.

Evidence: packages/apps/kubernetes/tests/nodegroups_default_test.yaml already uses kubernetesProvider.objects to mock lookup for MachineDeployment and KubevirtMachineTemplate — the exact kinds this logic reads. The pin path (highest blast radius: breaking byte-identity rolls every worker in every tenant cluster on the next platform bump) is covered by neither unit tests nor e2e (e2e is fresh-install only, so every group is new).

Fix: add helm-unittest cases with mocked lookups: (a) live MD + KMT with http disk-system renders http and keeps usePopulator: "false"; (b) live MD + KMT with pvc renders the clone; (c) no live MD plus a golden DataVolume present renders the clone.

Non-blocking follow-ups

  1. packages/system/kubernetes-worker-image/templates/dv.yaml:32 — the | default "4Gi" tail contradicts the documented ~4.15Gi minimum; if $.Values.storage is ever cleared it renders a too-small PVC (DataVolume too small). Make the final fallback 6Gi and quote it (already flagged by the Gemini bot).
  2. packages/apps/kubernetes/templates/cluster.yaml:127 — $useClone is latched on golden existence, not readiness. A new group rendered during the golden's import window clones an unpopulated source with no HTTP fallback; the worker DataVolume blocks until the golden reaches Succeeded, or indefinitely if the golden import fails. Consider gating on dig "status" "phase" "" $goldenDV | eq "Succeeded".
  3. Cross-StorageClass clone: if a tenant's worker storageClass differs from the golden's (replicated), CDI cannot CSI-clone and silently falls back to a host-assisted network copy — documented in values.yaml but unguarded. Fine to leave, worth a note.

@myasnikovdaniil
myasnikovdaniil force-pushed the feat/kubernetes-golden-worker-image branch from 3f3dd13 to f3589e4 Compare July 16, 2026 16:38
@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 Jul 16, 2026
@myasnikovdaniil

myasnikovdaniil commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Aleksei Sviridkin (@lexfrei) Thanks — the design was reworked so the image source is an explicit per-node-group choice rather than an implicit auto-clone, which addresses the blockers:

  • B1 (opt-in vs opt-out): the catalog is now explicitly opt-in, and a group clones only when it sets nodeGroups.<name>.image.builtin; omitting image keeps the HTTP import, unchanged from before. Nothing is created on a stock install, so there's no always-on 6Gi cost. The description and release-note are corrected. (d75eb3b, d8ce521)
  • B2 (e2e + cache): the kubernetes suites now enable the catalog and set image.builtin, so they actually take the clone path. The golden imports once from the Factory and every worker clones it — it is the single-import buffer talos-image-cache provided, so a separate mirror is redundant; the suite waits for the golden to reach Succeeded before spinning workers. (8725e77)
  • B3 (unit coverage): added suites covering http/clone selection, empty-map (hasKey) handling, absent-golden and both-set render failures, per-group installer image, and a pinned KMT content-hash test proving the image-omitted render is byte-identical to main. (f3589e4)
  • 1 (dv.yaml 4Gi): fixed — fallback is 6Gi, quoted. (d8ce521)
  • 2 (clone latched on existence, not readiness): now an explicit opt-in; a builtin group fails the render if the golden is absent, and e2e waits for the import to finish.
  • 3 (cross-StorageClass clone): unchanged; documented in the package values.yaml as a host-copy fallback caveat.

@myasnikovdaniil
myasnikovdaniil force-pushed the feat/kubernetes-golden-worker-image branch 3 times, most recently from ba1ab25 to 80c2c2c Compare July 20, 2026 10:18

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

NOT LGTM

feat(kubernetes): boot tenant worker disks from a shared golden Talos image (CDI clone). Two MAJOR issues around catalog-entry lifecycle and version-drift guarding block it. Verified by execution: helm-unittest across three charts, go build/go vet, and a render-diff of the default path against main (byte-identical apart from random tokens).

Findings

[MAJOR] packages/system/kubernetes-worker-image/templates/dv.yaml:6-32 + packages/apps/kubernetes/templates/cluster.yaml:142-145 — removing a catalog images[] entry silently blocks ALL future reconciliation of any existing tenant pinned to it. The golden DataVolume lacks helm.sh/resource-policy: keep, so removing (rather than appending) an entry prunes the DataVolume + its CDI PVC. Then cluster.yaml:142 does a lookup on that golden and cluster.yaml:144 fail()s the entire chart render when nil — aborting the whole Kubernetes HelmRelease, not just the disk block. An unrelated tenant then gets stuck on its next Helm action (scale / addon / RBAC edit). This is strictly worse than the vm-disk precedent, which fails at resource level only. Add resource-policy: keep to the golden DV and/or make the missing-golden path degrade instead of failing the whole render.

[MAJOR] packages/apps/kubernetes/values.yaml:307-308 vs packages/system/kubernetes-worker-image/values.yaml:44-46 — no automated guard ties the tenant chart's default Talos (version, schematicID) to the catalog's default images[] entry. They are two independently-edited files; a future one-sided bump silently breaks every fresh install using image.builtin: {}, and no existing test catches the drift. Add a test/lint asserting the two defaults stay in sync.

Claim mismatches / caveats

  • [PARTIAL] Stale unit-test counts in the PR body; actual is 193/193 kubernetes, 88/88 platform, all green.
  • [UNVERIFIABLE] live-cluster claim — outside the hermetic toolset.

Reconciliation with prior feedback (lexfrei CHANGES_REQUESTED)

  • These findings are on different grounds than lexfrei's B1 — independent convergence, not overlap.
  • lexfrei's B1 "image-cache workaround removed leaving e2e more exposed" is addressed: the removed talos-image-cache.sh / e2e-talos-image-cache.yaml / talos-image-cache_test.bats are replaced by the golden clone (hack/e2e-chainsaw/_lib/run-kubernetes.sh:334-339 sets image.builtin: {} on worker groups and waits for the golden import at 263-269, explicitly as the #3231 mitigation).
  • lexfrei's "opt-in / off by default, so the production import-reduction premise doesn't happen" is not resolved for the production default path (by design): the default/omitted path renders identically to main and does not use the golden clone. The feature was deliberately reframed as an opt-in catalog, so production imports are only reduced for tenants who explicitly opt in.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Aleksei Sviridkin (@lexfrei) follow-ups 2 and 3 are now closed in 2ab125766 and 3e0b544da. Recap of all six points, since the branch moved after your review:

B1 — opt-in inverted. Correct, and the description and release note were wrong. Both now state opt-in via bundles.enabledPackages. The vm-default-images precedent stands: a 6Gi replicated volume on every install, including VM-only and app-only clusters, is a real cost. No default change.

B2 — e2e never enables the golden. Fixed: hack/e2e-install-cozystack.bats adds cozystack.kubernetes-worker-image to enabledPackages, the kubernetes suites set nodeGroups.md0.image.builtin: {}, and run-kubernetes.sh waits for the golden to reach Succeeded before applying the CR — so a factory stall surfaces as a self-contained failure rather than an opaque node-join timeout. The OIDC suites run minReplicas: 0, so they provision no worker boot disks either way.

B3 — untested lookup pin. Removed rather than tested: the disk-system source now derives purely from .group.image, so there is no cluster-state-driven flip left to guard and the no-worker-roll guarantee is structural. Pinned by a KMT content-hash regression test.

Follow-up 1 — 4Gi fallback. Fixed: 6Gi, quoted.

Follow-up 2 — readiness vs existence. Fixed in 2ab125766: the clone now requires phase: Succeeded. It fails rather than falling back to HTTP deliberately — a fallback would make the source cluster-state-dependent, flipping the content-hashed KMT name once the golden went Ready and rolling the whole group. App HelmReleases retry every 17s with no remediation (Strategy.Name=RetryOnFailure), so a cluster created during the import window self-heals.

Follow-up 3 — cross-StorageClass. Fixed in 3e0b544da, and it deserved more than the doc note it had: a silent host-assisted fallback is exactly the pod-network transfer this PR exists to remove. The render now fails when both StorageClasses are known and differ, and skips when either is empty (cluster default, not resolvable at render). Defaults match on both sides, so the common path is unaffected. This rules out the guaranteed-host-assisted case, not every one — a matching StorageClass still leaves the strategy to the CDI StorageProfile.

One point of yours I'd rather keep on the record than mark closed: a single green E2E is weak evidence against an intermittent factory stall, and that cuts both ways — it doesn't prove the clone path fixed #3231 either, only that the path works end to end. The structural claim is narrower than the original description implied: the golden still does exactly one factory fetch, same as the cache it replaces, so the delta isn't "fewer factory dependencies" but that the N-way fanout moved off the pod network onto the storage layer.

Unit tests: kubernetes 197/197, worker-image catalog 3/3.

myasnikovdaniil added a commit that referenced this pull request Jul 20, 2026
A helm fail() aborts the entire chart render, so the golden-image guards
took down the whole Kubernetes HelmRelease of every tenant pinned to that
golden — every resource, not just the worker disk — blocking unrelated
scale and addon operations. Two changes narrow when that can happen.

Only phase=Failed aborts the render now. A golden mid-import is transient:
CDI holds the worker DataVolume until the source populates, which is the
correct behaviour and recovers unattended, so it no longer fails the
render. Failed never recovers without operator action, so pinning workers
to it is worth the abort. Falling back to the HTTP import is still not an
option — it would make the disk source cluster-state-dependent and flip
the content-hashed KubevirtMachineTemplate name once the golden went
Ready, rolling every worker in the group.

Goldens now carry helm.sh/resource-policy: keep, so removing an images[]
entry orphans the DataVolume instead of pruning it. Pruning a golden that
tenants still clone was the main way to trip the missing-golden fail() by
accident; removal now stops managing the golden and leaves cleanup to the
operator.

Reported by @IvanHunters in review of #3294.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
myasnikovdaniil added a commit that referenced this pull request Jul 20, 2026
`image.builtin: {}` resolves its golden from the kubernetes app's own
talos defaults and then requires the kubernetes-worker-image catalog to
hold a matching (schematicID, version) — the tenant render fails outright
when it does not. Those defaults live in independently edited files, so a
one-sided Talos bump silently breaks every fresh install using
image.builtin, with nothing catching it until a ~95-minute e2e run does.

The StorageClass is the same trap from the other direction: CDI cannot
CSI-clone across classes, so the render also rejects a group whose
storageClass differs from its golden's, and changing one file's default
alone brings that guard down on the default path.

Both assertions are mutation-proven: each fails on its own axis when the
corresponding default is changed alone, and passes once it is restored.

Reported by @IvanHunters in review of #3294.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

IvanHunters both MAJORs are fixed — thanks, the first one caught something I had actively made worse.

MAJOR 1 — fixed in ef7608525. Two parts, matching your "and/or":

Goldens now carry helm.sh/resource-policy: keep, so removing an images[] entry stops managing the DataVolume rather than pruning it. That removes the accidental path into the missing-golden fail() entirely — a golden now only disappears if someone deletes it by hand.

The render also no longer aborts on "golden not ready", only on phase: Failed. Worth flagging explicitly: your review is against 80c2c2cba, and about 40 minutes after you filed it I pushed a readiness gate that failed the render on any phase other than Succeeded — that is, on a transient state. Your blast-radius argument applies to that far harder than to the code you actually reviewed: a catalog bump recreating a same-named golden would have aborted renders for every pinned tenant for the length of an import, blocking unrelated scale and addon operations. The gate is now narrowed to the one phase that never recovers without operator action. Failed is the only terminal failure phase in containerized-data-importer-api (24 phases, checked against the module source rather than assumed) — everything else is transient, and CDI holds the worker DataVolume until the source populates, which is the behaviour we want anyway.

I did keep the fail() for a genuinely absent golden. There is no deterministic fallback to degrade to: rendering the HTTP import instead would make the disk source cluster-state-dependent and flip the content-hashed KubevirtMachineTemplate name once the golden appeared, rolling every worker in the group. With the keep policy in place the only remaining trigger is a deliberate manual delete of a golden that tenants still clone, which I think is worth failing loudly on. Happy to revisit if you read it differently.

MAJOR 2 — fixed in eac067b78. New hack/check-worker-image-catalog-defaults.bats, picked up automatically by make unit-tests. Both assertions are mutation-proven: bumping the app's Talos version alone fails the first, changing the catalog's StorageClass alone fails the second, and both pass once restored.

I widened it past what you flagged. The StorageClass has the identical shape — two independently-edited defaults feeding a render-time hard-fail — and the cross-StorageClass guard added for lexfrei's follow-up 3 is exactly what turned that into a brick, so covering (schematicID, version) alone would have been arbitrary.

One precision on "no existing test catches the drift": a catalog-side bump is caught today, by the pinned golden name in dv_test.yaml. An app-side bump is caught only by e2e. So the coverage was asymmetric and slow rather than absent — which if anything supports the finding, since a ~95-minute remote run is a poor guard for a one-line default edit.

On the test counts — you were right, and my earlier correction was incomplete. 193/193 was accurate for the commit you reviewed. The body now reads 198/198 for kubernetes and 88/88 for platform; I had corrected the kubernetes number and left platform 82/82 stale until you flagged it.

On the live-cluster claim — agreed it is outside a hermetic toolset and worth treating as unverified. For what it is worth on CI: the last completed e2e run failed at install on a snapshot-controller CrashLoop cascading through piraeus-operator-crds → piraeus-operator → linstor, which this diff cannot reach — these commits touch tenant-render-time templates, tests, and a catalog values comment, with no install-path value changed, and the preceding run installed cleanly from the same inputs. A fresh run is in flight and will settle whether that was a flake.

Second review pass, all four are prose that describes something the code
beside it does not do.

The talos.version description still said the support matrix is evaluated
against that value only and that an image.* override is not checked against
it. That was true when it was written and stopped being true one commit later,
when the matrix moved into a named template and gained its second call site.
cozyvalues-gen had propagated the stale sentence into values.schema.json, the
README, the Go types and the ApplicationDefinition schema, so the operator and
the dashboard both read it.

The guard block claimed an empty lookup makes all of its guards no-ops under
`helm template`. It does for the phase, StorageClass and size guards, which
read fields of a golden that was found. For the absence guard an empty lookup
IS the failure condition, so a builtin pool cannot be rendered by anything with
no cluster to read, and its unit tests have to mock the golden. Worth knowing
before someone runs helm lint against it and files a bug.

The e2e golden wait promised a stall would surface "with the factory in the
message". `kubectl wait` prints only "timed out waiting for the condition on
datavolumes/<name>" on expiry, which names neither the URL nor the importer's
error, so the step reported a stall about as legibly as the node-join timeout
it exists to replace. It now describes the DataVolume and tails the importer
Pod before failing; CDI writes the HTTP error and the source URL into the
Running condition message.

The catalog said a golden's StorageClass must be snapshot-capable. The
platform pins cloneStrategyOverride: csi-clone, which needs no
VolumeSnapshotClass, so that was an over-constraint on a value operators are
told to change.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
…laced escaping

hack/talos-reconcile-heredoc_test.bats renders the pool with hostile Talos
image coordinates and runs the reconcile Job's unquoted heredoc through a real
shell, to prove the escaping holds. The render guard added a commit ago refuses
those values before they reach the heredoc, so the test could no longer get a
Job to extract and failed with "render produced no Job command".

The chart's own invariant gives a field two ways to be safe here, escaped or
render-validated, and this change moved schematicID and version from the first
to the second without moving the test with them. Both arms are now asserted
rather than one: a case pins that hostile coordinates are refused at render,
and the literal-through-a-real-shell case keeps proving the escaping on
installerRepository, which stays free-form because it is not part of the
DataVolume name. registryMirrors is untouched. The file header says which field
is in which arm, so the next person editing either does not have to derive it.

Verified by mutation: disabling the schematicID guard turns the refusal case
red. Whole `make unit-tests` green, 1485 bats cases, which is what should have
been run before the previous push instead of the suites this branch happened to
touch.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

NOT LGTM

First, the size. 6555 changed lines across 37 files is past what one review round can hold, and roughly 2400 of those deletions are the ghcr.io pull-through mirror teardown that belongs to the stacked #4007, not to this feature. I reviewed the golden-image change and looked at the mirror removal only where the two touch. The natural seam is exactly that split: land #4007 on its own, and this PR shrinks to the catalog package, the kubernetes-nodes render path, and the schema. What follows is the feature's review, not a full account of the mirror teardown.

On the feature itself: the clone path holds up under render diff and per-conjunct mutation, and adopting it rolls no existing worker. Four things block merge. The catalog's operator knobs cannot be set through any supported path, so the two scenarios the PR body opens with (air-gapped and non-replicated storage) have no route to the fix the guards tell operators to apply. The extra transient pool capacity the clone needs is written down only in the CI sandbox. The new package's own instructions point an operator at a Kubernetes CR field that has been inert since the Phase 2 split. And talos.version's description promises a support-matrix guarantee that the guard's own contract, in this same PR, says it does not provide.

Still open from my earlier rounds

Open, second round: catalog value changes collide with DataVolume spec immutability (packages/system/kubernetes-worker-image/templates/dv.yaml:39, raised 2026-08-19). The golden's name is still keyed only on (schematicID, version) at dv.yaml:30, while storage, storageClass and the source URL stay ordinary mutable spec fields with no render-time guard against a bump on an entry whose golden exists. What changed is the documentation, not the code: values.yaml:62 now says an entry is "effectively immutable once its golden exists" and values.yaml:76 spells out that raising storage "is rejected on the next upgrade... the only way out is deleting the golden". Documenting a footgun is worth something, but the recovery it names walks straight into the deletion blast radius under Operational risks below. A render-time lookup on the existing golden that fails with "entry X changed after its golden was created, add a new entry instead" costs about as much as the two paragraphs already written.

Partially closed: the support matrix now has a second call site, and it still cannot reject what a pool can reach (raised 2026-08-19 against packages/apps/kubernetes/templates/cluster.yaml:21). The ask was the second call site and it is there: nodegroup.yaml:152-156 re-invokes the shared helper against the resolved per-group version. The residue is now a documentation defect rather than a missing guard, filed below as the fourth MAJOR.

Closed since my last round, verified against the code rather than the commit messages: the e2e mirror splice (the talos_spec_block helper is gone repo-wide and talos.registryMirrors is both computed and applied at talos-reconcile-job.yaml:338-343); helm.sh/resource-policy: keep on the golden (dv.yaml:17, asserted by a passing test); the tenant-string injection surface (nodegroup.yaml:135-143 now rejects an uppercase schematic, a version without the leading v, and a non-URL factory before any of the three reaches a DataVolume name); golden discoverability (two working queries at values.yaml:33-57, and the kubernetes-worker-image.cozystack.io/golden annotation they depend on is really emitted at nodegroup.yaml:248); and the missing default-sync guard (hack/check-worker-image-catalog-defaults.bats, 6 checks, auto-discovered by the hack/*.bats glob).

Claim mismatches

[PARTIAL] "Talos release outside kubernetes support window, reached either through talos.version or through image.* override" (What fails at render). The second call site exists and is fed the resolved version, but with one matrix row and a documented fail-open it can only reject a v1.13 pairing. See the fourth MAJOR for the executed render and the contradiction with _helpers.tpl. Relatedly, the guard standing in for that call site in CI is grep -c 'include "kubernetes-nodes.assertTalosSupportsKubernetes"' plus grep -q '"talosVersion" \$reqVersion' over the template text (check-worker-image-catalog-defaults.bats:96-111). It proves the call site exists and is fed the right variable, breaks on a rename, and says nothing about behaviour. Once the matrix carries a second row this becomes testable for real, and the source-text grep should give way to that case.

[PARTIAL] The release note's "Enable optional cozystack.kubernetes-worker-image package to seed one golden DataVolume per (schematicID, version) in cozy-public". True for the single shipped pair only. Per the first MAJOR there is no supported way to add a second entry, so "per (schematicID, version)" describes the template, not what an operator can reach.

Operational risks

  • Deleting a golden fails the render of every pool pinned to it, and the blast radius exceeds what is broken, for the same reason as the Failed finding above. resource-policy: keep plus the operator queries in values.yaml:40-55 make an accidental delete less likely, and the printed remedy does clear the state, which is why the deletion case is here rather than in Findings. Worth deciding on purpose whether a missing golden should stop a pool's release or leave the worker DataVolume Pending with CDI reporting it.
  • Every builtin worker disk in the cluster becomes a CSI clone of one source PVC in cozy-public. What LINSTOR does with N simultaneous clones of a single source, and whether clone placement is constrained to the nodes holding the source's replicas, is not established anywhere in this PR and is not something a static review can settle. It decides whether the feature scales past a handful of concurrent scale-ups, so it is worth measuring before this is recommended to anyone.

Caveats

  • Not executed, needs a live cluster, out of scope here: CDI's actual clone-strategy selection for a matching StorageClass, whether the DataVolume spec is unpatchable in the way values.yaml:62-68 asserts, the golden's import against a real factory, LINSTOR clone placement and concurrency, and Server-Side Apply behaviour when the new image key first reaches a live KubernetesNodes. The "verified on dev10" note at nodegroup.yaml:228-229 is the author's observation, not something this review reproduced.
  • The no-roll claim was verified rather than taken. tests/__snapshot__/render_snapshot_test.yaml.snap is untouched by the diff and its 12 snapshots pass, and a base-versus-head render diff with image omitted came back byte-identical across six value sets (defaults, talos.registryMirrors, a GPU pool, kubelet plus logSerialConsole, the podCpuLimit/podCpuRequest pair, and talos.version: v1.13.7), with the content-hash KubevirtMachineTemplate name holding at kubernetes-myk8s-md0-a6ac3e. instanceType-sized pools are not covered by the snapshot because that branch resolves through lookup; the disk-system block is identical either way, so this is a gap in the pin rather than in the conclusion.
  • Checked and found sound: cross-namespace clone authorization already exists (packages/system/kubevirt-cdi/templates/cdi-cr.yaml:25-46 binds datavolumes/source create for system:serviceaccounts in cozy-public), so no new RBAC is needed; the cold-install ordering reaches the namespace creator transitively, kubernetes-worker-image to cozystack.kubevirt-cdi to cozystack.cozystack-basics, matching vm-default-images; the new golden does not appear in the tenant vm-disk source dropdown, because SourceField.tsx:7,39 filters cozy-public PVCs on the vm-default-images- prefix; the reconcile Job is content-hash named at talos-reconcile-job.yaml:478-484, so an image.*.version change produces a new Job and the in-guest installer really moves; the installer-registry asymmetry is disclosed twice, at values.yaml:122 and values.yaml:135, so an air-gapped pool is told it must also point talos.installerRepository; the new bats file is picked up by the hack/*.bats glob (Makefile:161) and the chart's test target by hack/helm-unit-tests.sh, both green (6 and 5 tests, and 102 helm-unittest cases in kubernetes-nodes); go build ./... and go vet on the touched packages are clean and zz_generated.deepcopy.go is regenerated; the new render-time regexes reject only values that could not have worked before; the cozyrds diff adds only the image property with no change to resourceNames, secrets, services or any RBAC surface; an empty or null images catalog renders nothing rather than failing; and the ghcr.io mirror deletion leaves no dangling references, with a root-cause citation for why the mirror was not the node-join fix.
  • Refuted and recorded so they are not re-derived: the dashboard dropdown exposure; the cross-namespace clone RBAC; the missing spec.components being reachable by editing the Package CR by hand (helm-controller re-applies the rendered manifest and the platform chart owns the object); and a suspected coherence gap where a pool pinning image.factory.imageFactoryURL to a mirror would silently pull its installer from the public registry, which the two disclosure notes above already cover.
  • The diff carries #4007's ghcr.io mirror removal, reviewed only where it touches this change: run-kubernetes.sh no longer emits a spec.talos block at all and both tenant CRs take chart defaults, which the render corners above cover.

Recommended follow-ups

  • Run this on a disposable dev cluster: enable the catalog, create a builtin pool, confirm no host-assisted upload pod appears alongside the clone, and record peak pool usage across the clone-and-grow window so the sizing note in the second MAJOR can carry a real number instead of an estimate.
  • vm-default-images and the other nine packages registered through cozystack.platform.package.optional share the unreachable-values gap. Giving that helper a components parameter fixes all of them at once and is a smaller change than a per-package workaround here.
  • The e2e golden wait at run-kubernetes.sh:4665-4677 reports nothing when the outer timeout 12m fires before the inner kubectl wait starts, which is the case where the golden was never created because the catalog HelmRelease failed. A describe of the Package and the HelmRelease on the timeout path would name that failure instead of leaving exit 124.

Findings not anchored to changed lines

These reference code outside this PR's diff (unchanged files, or lines outside a hunk), so GitHub cannot render them inline.

[MINOR] packages/extra/computeplane/templates/cluster.yaml:207 the pool-shape allowlist was not updated and no decision was recorded

The pick list there carries a comment asking for it to be kept "in sync with the kubernetes-nodes values schema's pool-shape params", warning that a denylist "silently re-opens the hole every time kubernetes-nodes gains a new top-level value". This PR adds such a value and does not touch the file. The allowlist shape makes the outcome safe rather than broken, and the same comment argues a tenant should not pick the worker OS image, so exclusion may well be right. But image.builtin is a choice among operator-curated catalog entries, not the arbitrary URL that image.factory.imageFactoryURL is, and those are not obviously the same call. As it stands ComputePlane pools cannot use the feature, and because the NodeGroup schema has no additionalProperties: false, nodeGroups.<pool>.image.builtin on a ComputePlane CR is accepted, stored, dropped at render and never reported. Add image to the list, add it restricted to builtin, or say in the comment that it is excluded on purpose.

{{include "cozystack.platform.package" (list "cozystack.kubevirt" "default" $ $kubevirtComponents) }}
{{include "cozystack.platform.package.default" (list "cozystack.kubevirt-cdi" $) }}
{{include "cozystack.platform.package.optional.default" (list "cozystack.vm-default-images" $) }}
{{include "cozystack.platform.package.optional.default" (list "cozystack.kubernetes-worker-image" $) }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] none of the catalog's four documented values can be set through a supported path

cozystack.platform.package.optional takes three arguments (name, variant, root) and emits spec.variant and nothing else, so the Package it writes carries no spec.components. hr.Spec.Values = pkgComponent.Values at internal/operator/package_reconciler.go:302 is the only place a component's HelmRelease values come from. So imageFactoryURL, storageClass, storage and images are documented as operator knobs and reachable by nobody.

$ helm template cozystack-platform packages/core/platform --values /tmp/plat.yaml   # bundles.enabledPackages: [cozystack.kubernetes-worker-image]
kind: Package
metadata:
  name: cozystack.kubernetes-worker-image
spec:
  variant: default          # no components, no values

$ sed -n '34,50p' packages/core/platform/templates/_helpers.tpl
{{- define "cozystack.platform.package.optional" -}}
{{- $name := index . 0 -}}
{{- $variant := default "default" (index . 1) -}}
{{- $root := default $ (index . 2) -}}
...
spec:
  variant: {{ $variant }}   # <- the whole body

Three consequences follow, and the first two are the cases the PR body opens with. An air-gapped or rate-limited operator cannot repoint the golden at a mirror, so the single import goes to factory.talos.dev or it does not happen. A cluster whose worker pools sit on anything other than replicated cannot move the golden onto their class, and the StorageClass guard at nodegroup.yaml:210 then refuses the render while telling them to "Set the kubernetes-worker-image catalog entry's storageClass to %q", an action they have no path to perform. Its second suggestion, "point this pool at %q", is blocked on an existing pool too, because KubernetesNodes.spec.storageClass carries x-kubernetes-validations: self == oldSelf. Only "switch this pool to image.factory" is available, which is the feature turned off. And an operator on a custom schematic, which talos.schematicID's own description invites, cannot add an entry for it.

The fix shape is in this same file. gpu-operator routes through the four-argument cozystack.platform.package with a components dict at iaas.yaml:143-158 and its values arrive; backupStorage at bundles/system.yaml:278-287 is the same pattern with the silent-drop trap written out beside it. Giving optional a components parameter fixes this package and the other nine registered the same way. A test that renders the platform with the package enabled and asserts the emitted Package carries components.kubernetes-worker-image.values is red today.

## @param {string} storageClass - Default StorageClass for the golden image(s). Must match the StorageClass of the worker disks that clone it: CDI cannot CSI-clone across StorageClasses and would silently fall back to a host-assisted copy over the pod network, so the tenant render rejects a mismatch outright. Overridable per image.
storageClass: "replicated"

## @param {quantity} storage - Default storage size allocated for each golden image. Must be >= the imported raw image virtual size and <= the smallest worker diskSize that clones it. The Talos v1.13.x openstack raw is ~4.15 GiB, so 6Gi leaves headroom. Overridable per image, but only when the entry is added: a DataVolume spec cannot be patched in place, so raising this on an entry whose golden already exists is rejected on the next upgrade — a Talos version whose raw outgrows it needs a new `images[]` entry, which it gets anyway since the name is keyed on the version.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] the clone needs transient pool headroom that is stated only in the CI sandbox

A clone target larger than its source is populated as a clone of the source size and then grown, so provisioning one worker peaks at the golden plus a temporary clone PVC plus the grown target instead of just the target, times the replica count of the DRBD class. The repository knows this. It is written down once, in the test harness:

$ sed -n '84,95p' hack/e2e-prepare-cluster.bats
    # 300G (sparse) for the per-node LINSTOR/ZFS data pool. The kubernetes
    # suites now boot workers by CDI-cloning the golden Talos image in
    # cozy-public and resizing the clone up to the worker diskSize; that
    # clone+grow needs transient headroom (the golden itself, a temp clone
    # PVC, and the grown target) on top of the full suite's other PVCs.
    # At 200G the pool ran right at its limit — the resize-grow then failed
    # with "storage pool does not have enough free space", stalling worker
    # boot until the kubernetes suites hit their node-ready timeouts and the
    # whole run blew past the 180m budget.

$ grep -rn 'headroom' packages/system/kubernetes-worker-image/ packages/apps/kubernetes-nodes/values.yaml
packages/system/kubernetes-worker-image/values.yaml:76: ... The Talos v1.13.x openstack raw is ~4.15 GiB, so 6Gi leaves headroom.

That comment describes a property of the feature, not of the sandbox, and the only operator-facing "headroom" sentence in the tree is about the golden versus the raw image. An operator who enables image.builtin on a pool that is comfortable today gets worker DataVolumes that never populate, VMs that never boot, MachineHealthCheck remediating them, and the cause visible only in LINSTOR events: an indefinite silent wait on a legal input. Either the sizing requirement belongs in storage's and diskSize's descriptions with the arithmetic spelled out, or the shortfall needs to surface on the pool rather than in the storage layer.

## config; without it no golden is created and every worker imports over HTTP.
##
## A tenant node group opts into a clone explicitly by setting
## `nodeGroups.<name>.image.builtin` (optionally overriding schematicID/version) in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] the package tells the operator to set a field that does nothing

The header block says a node group opts in "by setting nodeGroups.<name>.image.builtin (optionally overriding schematicID/version) in a Kubernetes resource". spec.nodeGroups was removed from the Kubernetes CR in the Phase 2 split, and packages/apps/kubernetes/README.md:98 states what remains of it: the field is "still accepted and stored on an already-upgraded Kubernetes CR... but they have no effect: editing them... does nothing, and the API returns an admission warning pointing you at the KubernetesNodes resources". Pools are separate KubernetesNodes resources, which this same file's operator query at lines 40-42 gets right by reading kubernetesnodes and .spec.image.builtin.

So the file contains both the working instruction and a broken one, and the broken one comes first. An operator who follows the sentence rather than the query enables the catalog, edits the parent CR, sees a warning that is not an error, and their workers keep importing over HTTP. tests/dv_test.yaml:7 carries the smaller version of the same slip, naming packages/apps/kubernetes as the chart that reconstructs the golden name when it is packages/apps/kubernetes-nodes.


## @typedef {struct} Talos - Talos worker image configuration.
## @field {string} version - Talos release used for worker OS image and installer. Must satisfy the chart's Talos<->Kubernetes support matrix against the chosen `version`.
## @field {string} version - Default Talos release for this pool's worker OS image and in-guest installer. The pool can pin its own release via `image.builtin.version` or `image.factory.version`; when `image` omits it, this value applies. Must satisfy the chart's Talos<->Kubernetes support matrix against the chosen `version`. An `image.*` override is held to the same matrix, against whichever release it resolves to, so neither way in reaches a release outside the kubelet's support window.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] talos.version's description promises a matrix guarantee the guard's own contract denies

The new sentence reads: "An image.* override is held to the same matrix, against whichever release it resolves to, so neither way in reaches a release outside the kubelet's support window." The first clause is true, the conclusion is not. The matrix holds one row, "v1.13", and _helpers.tpl skips an unlisted Talos minor by design, which the helper's own comment states plainly three paragraphs above the code: "A Talos minor the matrix does not list passes. That is deliberate and unchanged... What the guard promises is that a pairing it KNOWS to be bad is refused, not that every pairing is known." Two files in this PR make opposite promises about the same guard, and the one an operator reads is the optimistic one.

$ helm template kubernetes-nodes-myk8s-md0 packages/apps/kubernetes-nodes --namespace tenant-root \
    --values /tmp/kn-base.yaml --skip-schema-validation \
    --set-json 'image={"factory":{"version":"v1.9.0"}}' | grep -E 'url: https|installer'
                  url: https://factory.talos.dev/image/ce4c98...c7515/v1.9.0/openstack-amd64.raw.xz
          image: factory.talos.dev/installer/ce4c98...c7515:v1.9.0

Kubernetes stays at the chart default v1.35 there, the render is clean, and the in-guest installer follows the override, so the pool both boots and upgrades onto a Talos release whose kubelet window excludes v1.35. Same result for v1.14.0 and v1.12.5. This matters more than a wording nit because cozyvalues-gen copies this prose verbatim into values.schema.json, the README parameter table and the -rd openAPISchema the dashboard renders, so the overstated guarantee ships to every surface an operator consults. Either give the matrix rows for the Talos minors an operator can actually point image.* at, or say what the guard does: a listed pairing known to be bad is refused, an unlisted minor is the operator's call. hack/check-worker-image-catalog-defaults.bats:85-91 already words it correctly, and its own failure message says "an unlisted Talos minor passes the guard unchecked".

which the worker DataVolume inherits. */}}
{{- $workerSC := .group.storageClass | default $.Values.storageClass | toString }}
{{- $goldenSC := dig "spec" "storage" "storageClassName" "" $goldenDV | toString }}
{{- if and $workerSC $goldenSC (ne $workerSC $goldenSC) }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] an empty pool storageClass skips the cross-class check and silently takes the copy path

$workerSC is .group.storageClass | default $.Values.storageClass, and storageClass: "" is a documented, schema-valid setting ("When empty, the cluster default applies", values.yaml:25). Set it on an image.builtin pool and the guard's first conjunct is falsy, so nothing compares the pool's effective class against the golden's replicated, CDI falls back to the host-assisted copy over the pod network, and that is the transfer the whole feature exists to remove, arrived at silently, which is the outcome the PR body says it refuses to render. The comment at lines 203-206 chooses this deliberately, and no fixture pins the choice:

guard / term                                          suite after deletion
if not $goldenDV                          (absence)   RED   1 failed
eq $goldenPhase "Failed"                  (terminal)  RED   1 failed
and $workerSC ...                         (worker SC) GREEN 102 passed   <- untested
and ... $goldenSC ...                     (golden SC) RED   1 failed, 1 errored
and ... (ne $workerSC $goldenSC)          (mismatch)  RED   5 failed, 4 errored
if $goldenStorage                         (presence)  GREEN 102 passed   <- untested
if lt $workerBytes $goldenBytes           (size)      RED   1 failed

The term that decides between "skip the check" and "refuse the render" on this corner is unprotected, as is the $goldenStorage presence gate one guard down. Reachable options are to resolve the cluster default via lookup on the default-class StorageClass, or to refuse image.builtin when the pool's effective class cannot be resolved. Either way the corner wants its own case.

would make disk-system's source depend on cluster state, so the source —
and with it the content-hashed KubevirtMachineTemplate name — would flip
once the golden went Ready and roll every worker in the pool. */}}
{{- $goldenPhase := dig "status" "phase" "" $goldenDV | toString }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] a Failed golden aborts the whole pool release even for a pool that already cloned successfully

The only lookup in this path is against the shared golden in cozy-public; nothing looks at whether this pool's own disk-system DataVolume already exists and succeeded. So a pool that cloned months ago and is healthy still re-reads the golden on every reconcile, and if someone follows the guard's own remediation text at line 180 ("delete the DataVolume to retry the import") and the retry lands in Failed, the next periodic Flux reconcile of that healthy pool aborts its entire render: replica changes, kubelet retuning, MachineHealthCheck timeouts, none of which touch the disk. No CR edit is needed to trigger it. The comment at lines 182-193 rejects exactly this blast radius for the mid-import case, in those words, and lands on the opposite answer for Failed without addressing why the pool that needs no clone should pay for it. Both review passes over this PR reached this independently. Gating the two golden guards on "this pool still needs a clone" is the shape that keeps the loud failure where it belongs, on a pool actually waiting for a disk.


# Every version the schema lets an operator pick must be in that row, or the
# guard rejects a combination the API advertises as valid.
for k in $(yq -r '.properties.version.enum[]' "$NODES_SCHEMA"); do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] the enum drift guard passes when it cannot read the enum

for k in $(yq -r '.properties.version.enum[]' "$NODES_SCHEMA") puts the whole assertion inside a loop over a command substitution whose failure is indistinguishable from an empty result:

$ yq -r '.properties.versionXX.enum[]' packages/apps/kubernetes-nodes/values.schema.json ; echo "exit=$?"
exit=0
$ n=0; for k in $(yq -r '.properties.versionXX.enum[]' .../values.schema.json); do n=$((n+1)); done; echo "iterations=$n"
iterations=0

So if the schema's version enum is renamed, moved, or the file relocates, the loop runs zero times and the test reports success: the drift it exists to catch is the change that disarms it. The two guards either side of it in this file do check ([ -n "$minor" ] at line 114, [ -z "$row" ] && return 1 at line 119), so this one is inconsistent rather than intentional. Capture first, assert non-empty, then iterate.

## @typedef {struct} Talos - Talos worker image configuration.
## @field {string} version - Talos release used for worker OS image and installer. Must satisfy the chart's Talos<->Kubernetes support matrix against the chosen `version`.
## @field {string} version - Default Talos release for this pool's worker OS image and in-guest installer. The pool can pin its own release via `image.builtin.version` or `image.factory.version`; when `image` omits it, this value applies. Must satisfy the chart's Talos<->Kubernetes support matrix against the chosen `version`. An `image.*` override is held to the same matrix, against whichever release it resolves to, so neither way in reaches a release outside the kubelet's support window.
## @field {string} schematicID - Talos image-factory schematic ID. Defaults to the cozystack-tested vanilla schematic. Operators using custom schematics (system extensions, kernel args) override here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] talos.schematicID's description was not updated with its two siblings

talos.version and talos.imageFactoryURL both gained a sentence about the pool pinning its own via image.* in this PR. schematicID did not, although image.builtin.schematicID and image.factory.schematicID override it identically, and although it now carries a render-time format constraint that did not exist before. The same prose pipeline as the fourth MAJOR applies, so a correctly regenerated schema does not make the text current. storageClass at values.yaml:25 is the same arm from the other side: it now decides whether a builtin pool renders at all, and its description says nothing about the golden.

{{- /* hasKey, not truthiness: an empty builtin/factory map means "the pool's
talos.* defaults", and Go templates treat an empty map as false. Mirrors
the resolution in nodegroup.yaml. */}}
{{- $grpImg := $group.image | default dict }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] the image resolution algorithm is hand-copied instead of shared, in the one PR that argues against exactly that

The hasKey-not-truthiness resolution of image.builtin / image.factory down to a schematic and a version exists twice, at nodegroup.yaml:98-113 and here, and the second copy's comment says so ("Mirrors the resolution in nodegroup.yaml"). The two agree today: both start from $.Values.talos.* ($talosVersion := $.Values.talos.version at line 387) and resolve the same way, so this is a drift risk rather than a live defect. It reads oddly beside the matrix refactor in this same PR, whose helper comment gives the argument for the other choice: "The matrix literal lives here, once, so the two call sites cannot drift apart." The boot disk source and the in-guest installer desyncing is the same silently-broken-pairing class that reasoning was written to prevent. This copy also does no format validation of its own resolved values and relies on nodegroup.yaml's regexes aborting the shared render first, which is true only because both templates live in one chart.

## @field {*WorkerImageBuiltin} [builtin] - Clone a golden image from the worker image catalog.
## @field {*WorkerImageFactory} [factory] - Import from an Image Factory / mirror (default).

## @param {WorkerImage} [image] - Worker OS image source for this pool. Default (omitted): import over HTTP from the `talos.*` Image Factory. Set `image.builtin` to clone a golden from the opt-in worker image catalog instead. Editing this on an existing pool re-images that pool.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] image lands one character from images with an unrelated meaning

The CR now carries image (worker OS image source) beside the existing images (images.kubectl, the chart's helper image), and images is introduced as "Optional image overrides for air-gapped or rate-limited registries", the same words that motivate image.factory.imageFactoryURL. An operator in an air-gapped cluster reading the parameter table has a real chance of opening the wrong one. This is permanent CR surface and costs nothing to rename now; workerImage or osImage reads unambiguously beside images.

Four blockers, plus the immutability finding carried over from the second
round and the minors alongside it.

The optional-package helper emitted a Package carrying a variant and
nothing else, and package_reconciler takes a component's HelmRelease
values from the Package alone, so the worker image catalog documented
four operator knobs that no supported path could set. That left the two
scenarios the feature exists for -- air-gapped and non-replicated
storage -- with a render guard naming a fix the operator could not
apply. The helper now takes the same components argument its
non-optional twin already had, iaas.yaml forwards a new platform-level
kubernetesWorkerImage block, and the nine other optional packages get
the same path.

The transient headroom a clone needs was written down only in the CI
harness. It is a property of the feature, so it belongs in diskSize and
in the catalog's storage description, with the arithmetic spelled out.

The catalog told operators to set nodeGroups.<name> on a Kubernetes CR,
inert since the Phase 2 split, one paragraph above the query that gets
it right.

talos.version promised the matrix covers every pairing; the helper's own
comment says the opposite three paragraphs above the code, and
cozyvalues-gen copies the optimistic half into the schema, the README
and the dashboard. Both now say what the guard does.

An entry whose golden exists is now enforced immutable rather than
merely documented so, because the documented recovery -- delete the
golden -- runs into the deletion blast radius the same file warns about.
Beside it the catalog refuses a duplicate (schematicID, version) and
pattern-checks both fields, so it can no longer create a golden whose
name a pool cannot reconstruct.

The absence and Failed golden guards now fire only for a pool still
waiting for its first disk. A pool that cloned months ago has no stake
in the source, and aborting its whole HelmRelease on a periodic
reconcile was the blast radius the mid-import case already refuses.

An empty storageClass on either side now resolves against the cluster
default before the cross-class comparison, which is the one corner that
cannot be a clone and was passing unchecked.

The image resolution is a named template both nodegroup.yaml and
talos-reconcile-job.yaml call, carrying the format checks with it, so
the boot disk and the in-guest installer cannot drift apart -- the
argument this PR already made for the support matrix.

The pool field is renamed image -> osImage. It landed one character from
the existing images (chart-internal helper overrides) with an unrelated
meaning and the same air-gapped motivation in its description. This is
new CR surface that has not shipped, so the rename is free now and a
migration later.

Also: the schema enum drift guard iterated over a command substitution
whose failure looked like an empty result, so a renamed property
disarmed it; the e2e golden wait reported nothing when the outer timeout
fired before the inner one; and ComputePlane's pool-shape allowlist now
records the exclusion of osImage rather than leaving it merely absent.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil
myasnikovdaniil changed the base branch from main to test/drop-ghcr-mirror September 2, 2026 13:16
…sserted

Ran every new conditional through deletion -- force the condition false,
and for an and-chain drop each conjunct -- then ran the suite against
each mutant. A mutant that leaves the suite green is a term nothing
asserts, so it can be removed in good faith by the next person to touch
the file.

Three survived, all in code this branch added:

The presence check on each drift comparison in the catalog. Without it a
golden whose spec carries no http source, no storage request or no class
compares an empty string against the rendered value, reports drift that
did not happen, and blocks every later upgrade of the catalog.

The (not $poolCloned) conjunct on the Failed golden guard. The suite had
a pool whose sibling had cloned and a pool whose own clone was
unfinished, but not the case the gate exists for: a golden that went
Failed after this pool was already whole.

The $workerSC presence term. Nothing covered an empty pool storageClass
with no default StorageClass to resolve it against, which is the arm
where the comparison is skipped rather than guessed.

One mutant still survives in this region, on the gate in front of the
second support-matrix call. It is unreachable from any schema-valid
render -- the matrix ships one row whose Kubernetes list is a superset
of the version enum -- and it is covered structurally instead, by the
greps in hack/check-worker-image-catalog-defaults.bats that assert both
call sites exist and that the second is fed the resolved version. That
is stated in the bats file rather than left to be rediscovered.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
…cuted

Both were introduced by this branch and both are the class of defect
this PR has already been reviewed for twice: a description that promises
something reality does not.

The clone headroom arithmetic counted the golden once per worker in
flight. The golden is a single shared PVC and a one-time cost for the
cluster; what scales with concurrency is the temporary clone PVC beside
each target. The number an operator would have sized against was
therefore too large by one golden per concurrent clone. Found by running
the sentence against the harness note it was derived from, which lists
the golden, a temp clone PVC and the grown target as the peak for one
clone in flight rather than as a per-worker multiple.

The cross-class guard told the operator to point the pool at the
golden's StorageClass. That field carries self == oldSelf in the values
schema, so on an existing pool -- the only pool that can reach this
guard -- it cannot be set. The message now names the reachable fix, the
catalog entry's storageClass together with the platform values key that
sets it, and says plainly that the pool-side match is a choice at
creation rather than a remedy here. The same defect on the catalog half
of this sentence was a review blocker; this is its other half.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
The second model read the same diff and reached five defects the
single-model pass did not, all in code this branch added.

A sibling pool answered for a pool that had never cloned. The
already-cloned gate matched worker disks by the name prefix
disk-system-<cluster>-<pool>-, and a pool named md0-gpu produces disks
carrying md0's prefix. With the golden absent or Failed, md0's guards
were suppressed by md0-gpu's disk and md0's new workers pointed at a
source that was gone -- silently, which is the outcome the guards exist
to prevent. Each cloned disk now names its own pool in an annotation and
the match is exact. This only touches the clone path, so no existing
worker's template changes.

A golden that declares no StorageClass sits on whatever default was in
force when CDI bound its PVC, not on today's. Substituting the current
default for it could wave through a cross-class copy or refuse a pool
that does match. The class now comes from the PVC, which is where the
concrete value lives.

More than one StorageClass may carry the default annotation at once --
the normal state midway through swapping a cluster's default -- and
Kubernetes resolves it by creation time. The helper emitted every match
concatenated, producing a class name that does not exist and refusing a
pool whose class matched the default actually in force. It now picks the
most recently created, as Kubernetes does.

A Talos version was accepted without its patch segment. Both regexes
took vMAJOR.MINOR, which names an artifact the Image Factory does not
publish and an installer tag that does not exist, so the failure landed
in the guest instead of at render.

And the remedy printed by the cross-class guard could not be followed.
The previous commit pointed it at the catalog entry's storageClass; the
immutability guard added by this same branch rejects that edit once the
golden exists, which is the only state in which this branch runs. The
message now names the two moves that work: a new (schematicID, version)
entry on the right class with the pool repointed at it, or deleting the
golden once nothing clones it.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

NOT LGTM

Second round, at ceeec044. Everything from the previous round is closed, and I checked each one against the code rather than against the commit messages: the catalog values are reachable and I rendered the Package to prove it, the clone headroom arithmetic is in both storage and diskSize, the package header now points at KubernetesNodes and says the parent CR field is inert, talos.version's description now says what the matrix actually promises, the catalog rejects duplicate and malformed entries and refuses an edit that would need an unpatchable DataVolume patch, the enum guard fails when it cannot read the enum, the resolution logic is one shared helper, and the field is osImage. The size problem that led my last review is gone structurally: the PR is retargeted onto test/drop-ghcr-mirror, so the mirror teardown is out of the diff and the stack is declared instead of implied. Existing workers still do not roll, verified by render rather than assumed.

One thing blocks merge, and it arrived with the fix for the empty-StorageClass corner I raised last round. Resolving the empty side made the comparison possible, but the two sides are now resolved from different authorities, and the mismatch aborts a healthy pool's entire release on a cluster-wide action that pool did not take.

Closed since my last round, checked against the code

  • Catalog values reachable. cozystack.platform.package.optional takes a components argument and emits the block; iaas.yaml:147-149 forwards .Values.kubernetesWorkerImage, declared at packages/core/platform/values.yaml:562. Rendering the platform with the package enabled and three overrides set put all three under spec.components.kubernetes-worker-image.values. The chart's own repository template needs a live OCIRepository, so I rendered from a copy with that template removed rather than claim a hermetic full-chart render.
  • Clone headroom stated where an operator reads it, with the arithmetic: "the peak is the golden, plus diskSize + golden storage for each worker cloning at that moment", plus the failure shape, in both kubernetes-worker-image/values.yaml:78 and kubernetes-nodes/values.yaml:60, and through cozyvalues-gen into the schema and the cozyrds openAPISchema.
  • The package header points at KubernetesNodes and names the parent CR field as accepted-but-inert; tests/dv_test.yaml names the right chart.
  • talos.version's description now matches _helpers.tpl: the matrix rejects a pairing it lists, and an unlisted Talos minor is the operator's call. The two files no longer contradict each other.
  • Duplicate and malformed catalog entries refused at dv.yaml:22-33, and dv.yaml:36-59 adds the drift guard that closes the DataVolume-immutability item I had carried open across two rounds. It names the entry, states the supported move, and says plainly that it is inert without a cluster to read.
  • The enum drift guard captures first and fails on empty.
  • Resolution factored into kubernetes-nodes.resolveOsImage, used by both nodegroup.yaml:96 and talos-reconcile-job.yaml:451. Behaviour-preserving: boot URL, installer image and golden name are identical to the previous head across four override shapes (builtin {}, builtin with a version, factory with a version, factory with a schematic and a mirror URL).
  • image renamed to osImage. The field does not exist at the base (osImage: 0 hits in the base values.yaml, no OSImage or json:"image" in the base types), so this is an internal rename and not a CR contract change, and it propagated to values.schema.json, README.md, types.go, zz_generated.deepcopy.go, the cozyrds openAPISchema and its keysOrder, the e2e and the bats guard.
  • ComputePlane records the decision: osImage is named in the exclusion comment on purpose, with the reasoning and the review reference, rather than being silently absent.
  • The e2e golden wait now dumps the Package and the HelmRelease on the outer timeout, which was my third follow-up, and it captures the DataVolume list inside the until condition so a transient get cannot skip the import check.
  • Existing workers do not roll: base-versus-head renders are byte-identical across five value sets (defaults, talos.registryMirrors, a GPU pool, kubelet plus logSerialConsole, talos.version: v1.13.7), and the snapshot fixtures are untouched by the diff.

Checked and found sound

  • $poolCloned is a real test, not a proxy: it matches a DataVolume in the release namespace on both the pool and golden annotations and on status.phase: Succeeded, and the annotations are emitted at nodegroup.yaml:272-273. The pool-exact annotation rather than a name prefix is the right call, for the reason the comment gives about md0 and md0-gpu.
  • I expected the $poolCloned gate to trade a loud render failure for a silent stall when a golden is deleted and the pool later scales up. The code answers it: the signal moves to the pending DataVolume of the worker that actually needs a clone. That is more precisely placed than a whole-release abort, and it is consistent with the blast-radius argument, so I am not filing it.
  • kubernetes-nodes.defaultStorageClassName handles both the current and beta annotations, and its most-recently-created tie-break matches how Kubernetes resolves more than one default. RFC3339 timestamps are fixed-width UTC, so the string comparison is chronological as the comment claims.
  • go build ./... and go vet ./api/... are clean; the generated values.schema.json, README.md and zz_generated.deepcopy.go all moved with the source.
  • The three chart-lint render errors are harness artifacts, not defects: the platform chart needs a live OCIRepository, the computeplane chart requires its release to be named computeplane, and the kubernetes-nodes entry is an instanceType lookup that is empty under helm template and fails identically at the base (nodegroup.yaml:303 there, :513 here). The six missing_refs are platform-injected values, absent at the base too.
  • Both template-comment-bloat flags are {{- /* */}} comments, stripped at render. The manifests this chart renders carry 88 non-# Source: comment lines at the base and 88 at this head, all of them shell comments inside the reconcile Job script.
  • The values-prose-drift flag on the top-level version field is stale: the caveat about unlisted Talos minors belongs on the field that carries the override, and talos.version now has it.
  • 117 helm-unittest cases and 12 snapshots green in kubernetes-nodes, 14 in kubernetes-worker-image, 6 bats checks green. shellcheck reports no errors, one warning (SC2034, an unused port at run-kubernetes.sh:4598) and the rest info or style.

Caveats

  • Still out of scope for a static review and unchanged from last round: CDI's real clone-strategy selection, LINSTOR clone placement and concurrency under N simultaneous clones of one source, the golden's import against a real factory, and Server-Side Apply when the new osImage key first reaches a live KubernetesNodes. The two guards that depend on lookup are inert under helm template by construction, so the drift guard at dv.yaml:36-59 and the PVC-authority resolution are verified by their unit fixtures and by reading, not against a cluster.
  • The MAJOR above was reproduced with a helm-unittest kubernetesProvider fixture, which mocks lookup responses. That is the same mechanism the chart's own suites use, so it exercises the template's real branch, but it is a model of cluster state rather than a cluster.
  • Merging now depends on test/drop-ghcr-mirror landing first, since the PR is based on it.

{{- if not $workerSC }}{{- $workerSC = $defaultSC }}{{- end }}
{{- if not $goldenSC }}{{- $goldenSC = $defaultSC }}{{- end }}
{{- end }}
{{- if and $workerSC $goldenSC (ne $workerSC $goldenSC) }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] the cross-class guard reads the golden's frozen class and the pool's live one, so moving the cluster default aborts a pool whose disks are correct

The golden side is deliberately resolved from its bound PVC, and lines 211-218 give the reason: a golden that declares no class "is on whatever default was in force when CDI bound its PVC", so reading the spec would "substitute today's default for it". The worker side gets no such treatment. $workerSC is .group.storageClass | default $.Values.storageClass, and when that is empty it becomes $defaultSC, which is today's default class. So for a pool at storageClass: "" the two sides are a frozen value and a live one, and any change to the cluster's default annotation puts them out of step.

The consequence is not a wrong disk, it is a stopped release. A pool that cloned successfully months ago, whose workers are all running on the same class as the golden, fails its whole KubernetesNodes render the next time Flux reconciles it, with no edit to the pool and none to the catalog. Replica changes, kubelet retuning and MachineHealthCheck timeouts go with it. The trigger is an admin moving storageclass.kubernetes.io/is-default-class to a new class, which the helper's own comment calls "the normal state midway through swapping a cluster's default".

Reproduced against the shipped catalog default, so the only non-default setting involved is the pool's own documented storageClass: "":

$ helm unittest .   # pool storageClass: "", golden spec storageClassName: replicated (the catalog default),
                    # cluster default moved to fast-nvme, and a Succeeded disk carrying this pool's
                    # kubernetes-worker-image.cozystack.io/{pool,golden} annotations
 FAIL  repro - pool empty + shipped catalog default + moved cluster default
    Error: execution error at (kubernetes-nodes/templates/nodegroup.yaml:686:32): nodeGroup "md0":
    storageClass "fast-nvme" differs from golden image "talos-worker-aaa-v1.13.6" storageClass
    "replicated" — CDI cannot CSI-clone across StorageClasses ...

values.yaml:25 presents the empty value as a supported path ("Leaving it empty resolves to the cluster's default StorageClass, and the same comparison is made against that"), and storageClass carries self == oldSelf, so the operator cannot answer this by editing the pool. The remedies the message offers are real but all cost something: a new catalog entry plus repointing osImage.builtin, deleting the golden, or leaving the feature.

Worth saying explicitly because the cheap fix is the wrong one: adding (not $poolCloned) here, matching the two guards above it, would spare the healthy pool and then let the pool's next worker take the host-assisted copy silently, which is the transfer this whole feature exists to remove. The symmetric fix is the one the code already argues for on the golden side. An already-cloned pool has bound PVCs of its own, and the DataVolume list at lines 167-174 is already being read, so the pool's frozen class is available from the same data. Compare frozen to frozen for a pool that has cloned, and today's default only for a pool that has not, and the false positive disappears without opening the silent path.

The size floor at lines 235-242 shares the missing gate but not the false-positive character: diskSize is mutable (api/apps/v1alpha1/kubernetesnodes/types.go:40 carries no XValidation), so lowering it below the golden on an already-cloned pool also aborts the whole release, but there the configuration really is wrong for the next worker, and lowering diskSize re-rolls the pool anyway. That one is the same blast-radius question I raised as [MINOR] last round, not a new defect.

{{- if not $goldenSC }}{{- $goldenSC = $defaultSC }}{{- end }}
{{- end }}
{{- if and $workerSC $goldenSC (ne $workerSC $goldenSC) }}
{{- fail (printf "nodeGroup %q: storageClass %q differs from golden image %q storageClass %q — CDI cannot CSI-clone across StorageClasses and would fall back to a host-assisted copy over the pod network. An empty storageClass on either side resolves to the cluster default StorageClass %q. Neither side can be moved in place: this golden's class is fixed for its lifetime, because a DataVolume spec cannot be patched, and the pool's storageClass is immutable on an existing pool. So add a NEW kubernetes-worker-image catalog entry with a different (schematicID, version) on storageClass %q (kubernetesWorkerImage.images in the cozystack-platform Package values) and point this pool's osImage.builtin at it; or, once no pool clones this golden, delete it and let the catalog recreate it on that class. Switching this pool to osImage.factory also resolves it." .groupName $workerSC $goldenName $goldenSC $defaultSC $workerSC) }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] the guard names a cluster default that was never consulted

$defaultSC is computed only inside if or (not $workerSC) (not $goldenSC) at lines 227-231, but the message interpolates it unconditionally. When the guard fires on two explicit, differing classes, which is the case the shipped defaults produce, the operator is told the render resolved something it never looked at:

$ helm unittest .   # pool storageClass: local, golden spec storageClassName: replicated
    ... An empty storageClass on either side resolves to the cluster default StorageClass "".

Either build that sentence only when a side was actually empty, or say which side it resolved.

{{- fail (printf "nodeGroup %q: storageClass %q differs from golden image %q storageClass %q — CDI cannot CSI-clone across StorageClasses and would fall back to a host-assisted copy over the pod network. An empty storageClass on either side resolves to the cluster default StorageClass %q. Neither side can be moved in place: this golden's class is fixed for its lifetime, because a DataVolume spec cannot be patched, and the pool's storageClass is immutable on an existing pool. So add a NEW kubernetes-worker-image catalog entry with a different (schematicID, version) on storageClass %q (kubernetesWorkerImage.images in the cozystack-platform Package values) and point this pool's osImage.builtin at it; or, once no pool clones this golden, delete it and let the catalog recreate it on that class. Switching this pool to osImage.factory also resolves it." .groupName $workerSC $goldenName $goldenSC $defaultSC $workerSC) }}
{{- end }}
{{- $goldenStorage := dig "spec" "storage" "resources" "requests" "storage" "" $goldenDV }}
{{- if $goldenStorage }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[NIT] one guard term is still unasserted

The mutation pass in 7660b9d0b picked up $workerSC, which was one of the two terms I flagged last round; if $goldenStorage is the other and is still uncovered. Deleting it leaves the suite green:

$ sed -i '' '236s/if \$goldenStorage/if true/' packages/apps/kubernetes-nodes/templates/nodegroup.yaml
$ helm unittest packages/apps/kubernetes-nodes
Tests:       117 passed, 117 total

For contrast, every other term in the same block goes red: the absence and Failed gates on $poolCloned (2 failed / 1 failed 1 errored), both StorageClass truthiness terms (1 failed 1 errored / 2 failed 2 errored), the mismatch term (9 failed 8 errored), the size floor (1 failed) and the PVC-authority fallback (1 failed).

…has cloned

Resolving an empty storageClass closed the corner where a cross-class
clone passed unchecked, and opened a worse one: the two sides of the
comparison stopped being the same kind of value. The golden's class came
from its bound PVC and could not move; the pool's came from today's
cluster default and could. An admin moving
storageclass.kubernetes.io/is-default-class, which the resolver's own
comment calls the normal state midway through a swap, then failed the
whole KubernetesNodes release of a pool whose disks were all on the
right class, which had changed nothing, and whose storageClass is
immutable so it could not answer.

A pool that has already cloned now reads its class from its own bound
PVC, the same authority the golden side uses. Today's default applies
only to a pool with no disk yet, which is the class its first clone will
actually get. Gating the guard on the already-cloned flag instead, the
way the two guards above it are gated, would have spared the same pool
and then let its next worker take the host-assisted copy in silence,
which is the transfer this feature exists to remove.

The failure message named a cluster default the render had not
consulted. It is computed only when a side is empty, and interpolated
unconditionally, so a mismatch between two explicit classes reported
resolving to . The sentence is now built only when a side really was
resolved from the default.

The size floor skipped whenever the DataVolume spec omitted a size, and
a golden whose spec is silent still has a bound PVC stating the
allocated one. The same authority argument as the class, and it makes
the gate observable: previously it could be forced either way with no
test noticing, which is how it survived a mutation pass that only ever
forced conditions false.

Six cases, including the reviewer's own reproduction. Three of them pin
precedence contracts nothing held before: the golden's declared class
and size beat its PVC, and a pool's declared class beats the PVC of its
own disks. The PVC is a fallback, never an override.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

My previous comment answered your first round and crossed with this one, sorry for the noise. This is the reply to the round at ceeec044.

All three fixed in 35d7a3a7.

The MAJOR is fixed the way you describe rather than by gating it. A pool that has already cloned now reads its class from its own bound PVC, which the DataVolume list at 167-174 was already giving me, so frozen is compared to frozen. Today's default applies only to a pool with no disk yet, which is the class its first clone will really get. You were right that (not $poolCloned) was the trap: it spares the pool and then hands its next worker the host-assisted copy in silence.

The message now builds the cluster-default sentence only when a side was actually resolved from one, and says which.

The NIT I fixed at the cause instead of adding a test on top. The size floor now reads the golden's PVC when the spec omits a size, same authority argument as the class one line up, and that makes the gate observable: it is now reached only when neither the spec nor the PVC states a size, where skipping and comparing against nothing are the same outcome.

Your if true mutant is what showed my own pass was half a pass. It only ever forced conditions false and dropped conjuncts, so a presence check whose true branch runs on input it was written to exclude read as covered. Both directions now, and rerunning it on this diff turned up three precedence contracts nothing was holding: the golden's declared class and size have to beat its PVC, and a pool's declared class has to beat the PVC of its own disks. The PVC is a fallback, never an override. Six cases added, one of them your reproduction.

Eight mutants still survive in this diff and I do not think any is a gap. Three are nil guards where forcing the branch calls dig on nil and gets the same empty value back. One is the short-circuit in front of the default-class lookup, where the assignments inside keep their own conditions. One is the size gate above, unreachable now. Two are the support-matrix pair, unreachable from schema-valid input because the row's Kubernetes list is a superset of the version enum, covered by the greps in hack/check-worker-image-catalog-defaults.bats instead. If you read any of those differently I would rather hear it than assume.

make unit-tests is green, 124 cases in kubernetes-nodes, snapshot untouched.

@SerjioTT

Copy link
Copy Markdown
Contributor

The catalog is opt-in twice, so a stock install still has every worker pulling ~4 GiB from the public factory. Fine as a first step, but the description reads like the flake is fixed, and as written it is fixed for the e2e suite, which enables both levels. Worth saying which of the two you want to become the default.

Answering this from an operator's seat, since our case falls exactly through the gap between the two opt-ins.

We run a Cozystack-based managed platform where the public Image Factory is not reachable from the cluster's egress at all. So we are the intended audience for this feature, and the operator-side work is already done on our side: mirror up, golden imported.

The catalog half is solved here, and it is worth saying so plainly. kubernetesWorkerImage in the platform values reaching spec.components.kubernetes-worker-image.values means an air-gapped operator repoints the single import through normal platform configuration instead of hand-editing a Package CR. That is the piece we needed and it works.

The consuming half has no equivalent lever. osImage defaults to {} (packages/apps/kubernetes-nodes/values.yaml:129), an omitted field means the HTTP import from talos.*, and nodegroup.yaml reads no _cluster key for the image source. So on our cluster, with the mirror and the golden both in place, a newly created KubernetesNodes still goes to factory.talos.dev and fails — unless whoever created it happened to type osImage.builtin into the spec. A consumer who came to get a Kubernetes cluster has no reason to know that worker OS images exist.

So the answer from here is that the pool-side opt-in is the one that needs a platform default. The catalog one is already an operator decision expressed in operator configuration; the pool one is a tenant-facing field with no operator-side lever at all.

What matters is when such a default applies. A render-time default — the chart filling osImage in for a pool that omitted it — is exactly what this PR is right to avoid: it changes the resolved source of every existing pool, moves the content-hashed KubevirtMachineTemplate name, and rolls the fleet on the next reconcile. That invariant should stay protected.

A create-time default does not have that property. A platform-level setting stamped into the spec when a KubernetesNodes is created, and never applied to one that already exists, leaves every stored HelmRelease value untouched, so no existing pool re-renders and nothing rolls. New pools arrive with the operator's choice already in their spec, where it is an ordinary field from then on. Mechanically this looks like the mirror of the defaulting that already exists: applySpecDefaults runs on the read path, in ConvertHelmReleaseToApplication, and what is missing is the same step on the write path in Create, gated to creation so it materialises into the HelmRelease values exactly once. The channel for the value itself already exists too — release.cozystack.io/helm-install-timeout is a per-kind knob shipped on the ApplicationDefinition and read by cozystack-api at startup, so an operator would set this the same way the catalog values are set in this PR, through the platform Package.

The wrong version of this is worth naming explicitly, because it looks like the easy answer: a default in the values schema is not it. Defaults are applied on read in cozystack-api (applySpecDefaults, called from ConvertHelmReleaseToApplicationWithMonitor) rather than written into the HelmRelease, so a schema default would surface on every existing pool at once and get persisted by the first kubectl apply or dashboard edit that follows — the same fleet roll, only delayed and unpredictable in timing.

None of this is a request against this PR, which is already XXL and scoped right. But since the default question is open in the thread, this is the shape that would let the feature reach clusters like ours — and the same mechanism would serve an air-gapped operator who just wants a default talos.imageFactoryURL, with no golden involved at all.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

LGTM with non-blocking notes

Nothing blocking: the default-path render is byte-identical to the base on every corner I rendered, the new package emits nothing until it is enabled, and the guards behave as described. What is left is a set of test-coverage and message-precision gaps, plus one claim in the PR body that my own mutation pass contradicts.

Closed since my last round

My previous review was NOT LGTM at ceeec044 on three findings, all in nodegroup.yaml. One commit has landed since, 35d7a3a7, and I diffed it rather than reading its message.

  • The MAJOR is closed. The cross-class guard compared the golden's frozen class against the pool's live one, so an admin moving storageclass.kubernetes.io/is-default-class failed the whole release of a pool whose disks were already correct. A pool that has already cloned now reads its class from its own bound PVC, so both sides are frozen; a pool with no disk yet is still compared against today's default, which is the class its first clone will get. The comment above the block records why gating on $poolCloned alone was rejected.
  • The MINOR is closed. The message named a cluster default the render never consulted whenever the guard fired on two explicit classes. The sentence is now built only when a side was actually resolved from the default.
  • The NIT is half closed, and I am re-filing the other half below. $goldenStorage now falls back to the golden's bound PVC when the DataVolume spec omits the size, which was the substance, and that fallback is asserted. The presence term itself is still unasserted.

worker_image_frozen_storageclass_test.yaml is new with the fix, 264 lines over seven cases, and it covers each of the three.

Findings

  • [MINOR] packages/apps/kubernetes-nodes/templates/nodegroup.yaml:318, the written .../pool annotation value is not pinned by any test, only the reader is
  • [MINOR] packages/apps/kubernetes-nodes/templates/_helpers.tpl:308, the legacy beta default-StorageClass disjunct is unreachable from any fixture
  • [MINOR] packages/apps/kubernetes-nodes/templates/_helpers.tpl:267, new format guards fail-close on talos.* values the API schema still accepts and the base still rendered
  • [MINOR] packages/apps/kubernetes-nodes/templates/nodegroup.yaml:264, the second remedy in the cross-class message does not do what it says
  • [MINOR] packages/system/kubernetes-worker-image/templates/dv.yaml:56, the drift guard is blind exactly when the live golden carries no storageClassName
  • [MINOR] packages/apps/kubernetes-nodes/values.yaml:108, version's description still names talos.version as the only matrix counterpart
  • [NIT] packages/apps/kubernetes-nodes/templates/talos-reconcile-job.yaml:387, $talosVersion is now dead
  • [NIT] packages/apps/kubernetes-nodes/templates/nodegroup.yaml:281, the if $goldenStorage presence term is still unasserted, carried from my last round

Claim mismatches

[PARTIAL] "mutation pass over every guard term this diff adds: each term deleted in turn, suite required to notice. Two survive, both on support-matrix guard". I ran 22 mutations across nodegroup.yaml, _helpers.tpl, talos-reconcile-job.yaml, dv.yaml and bundles/iaas.yaml and got three survivors, not two, and a fourth I found separately by hand. The second support-matrix call is one of them and it is genuinely pinned by the grep guard in hack/check-worker-image-catalog-defaults.bats:100-112, as claimed. The other two are the beta default-class disjunct and the written pool annotation above, and neither is covered by a grep guard. The fourth is the $goldenStorage presence term re-filed above, which I carried open from my previous round.

Caveats

  • Verified and found sound, so it is not re-litigated: the default path does not roll workers. helm template of the pool at merge-base and at head with identical release name and values is byte-identical (31299 bytes both sides), and so are the corners talos.version=v1.13.5, talos.imageFactoryURL=<mirror>, storageClass="", instanceType=u1.medium, talos.registryMirrors, version=v1.34. The content-hashed KMT stays kubernetes-myk8s-md0-2d362e for osImage omitted, osImage: {} and osImage.factory: {}, while the pre-existing mutable fields already re-hash it (talos.version gives fdeb88, diskSize gives 6d9b26). That is also the answer to osImage being a mutable identity input: it sits in the same class as fields the chart has always accepted as mutable, and the re-image is documented in values.yaml, values.schema.json, the cozyrds openAPISchema, the README and the Go doc comment.
  • Verified: the platform helper change has no blast radius on its nine other callers. Rendering packages/core/platform at merge-base and at head on values.yaml, values-isp-full.yaml and values-isp-hosted.yaml differs only by the added PackageSource; no existing Package CR changes, and none is emitted for the new package unless it is in enabledPackages.
  • Verified: the pieces the clone depends on exist. datavolumes/source in cozy-public is already granted to system:serviceaccounts (packages/system/kubevirt-cdi/templates/cdi-cr.yaml:26-45), CDI v1.64.0 has no feature gate on cross-namespace clone, packages/apps/vm-disk/templates/dv.yaml:24-26 is the in-tree precedent for the same source.pvc shape out of cozy-public, and the dataVolumeTemplates annotations survive both hops (CAPK v0.1.10 pkg/kubevirt/utils.go:70-73 deep-copies the template spec and only renames, KubeVirt v1.8.4 pkg/storage/types/dv.go:125 clones the annotation map onto the created DataVolume). Flux's ServiceAccount is bound to cluster-admin (internal/fluxinstall/manifests/fluxcd.yaml:7831-7847), so the lookup calls into cozy-public and the cluster-scoped StorageClass list resolve under helm-controller.
  • Mechanical sweep run on the added shell (hack/check-worker-image-catalog-defaults.bats, the changed hack/e2e-chainsaw/_lib/run-kubernetes.sh, hack/e2e-install-cozystack.bats, hack/e2e-prepare-cluster.bats). Eleven || true / 2>/dev/null hits, all refuted: the four in the new bats feed an explicit empty-check that fails the test, and the rest are diagnostic dumps or a wait loop that times out rather than passing. shellcheck on the new bats returns one SC2016 info on an intentionally single-quoted grep pattern. No RBAC was added, so the over-broad-verb sweep is vacuous here.
  • Noted, not blocking: the transient storage cost of the clone has no signal above the storage layer. hack/e2e-prepare-cluster.bats:95 raises the per-node sandbox pool from 200G to 300G because the clone-and-grow ran out of space and stalled worker boot until the suites hit their node-ready timeouts. For a customer the same shape is a peak of the golden plus diskSize + golden storage per concurrent clone, multiplied by the replica count of a replicated class, and values.yaml says plainly that a backend which cannot spare it does not fail, the clone just waits and the VM never boots. That is a legal input leaving a resource pending with no surfaced condition. I am not blocking on it because the feature is opt-in, off by default, documented in diskSize and in the catalog's storage description, and reversible by flipping osImage back.
  • Not executed, needs a live cluster and out of scope here: whether the CSI clone plus grow actually yields a bootable Talos root disk on a replicated class, whether the golden's PVC access mode matches what the worker disk needs, and whether helm-controller's SSA converges on a builtin pool. The render cannot answer any of these. The PR does put the clone path under the kubernetes-latest and kubernetes-previous suites and gates the pool on the golden reaching Succeeded first, which is the right coverage for it, but I have not observed that run.
  • Not exercised: isp-hosted. The PackageSource renders there but the Package does not, because the iaas bundle is off on that preset. That matches kubernetes-nodes-application, which lives in the same bundle, so there is no consumer stranded on that preset.

Recommended follow-ups

  • Goldens carry helm.sh/resource-policy: keep, so every Talos bump adds a new golden and leaves the previous one occupying its storage times the replica count until somebody removes it by hand. values.yaml carries the two queries that say when that is safe. Worth a scheduled check or a documented retirement step rather than relying on an operator remembering.
  • ComputePlane pools cannot reach the clone path at all, by the deliberate exclusion at packages/extra/computeplane/templates/cluster.yaml:206. That is a coherent decision for a module-owned image, but it means the per-worker Factory dependency stays for those pools; worth saying so on #3950 so the platform-level default lands there too.
  • Run cozystack-pr-test on a disposable dev cluster for the builtin path specifically: create the catalog, wait for the golden, create a pool on osImage.builtin, and confirm the worker disk is populated by a CSI clone with no importer or upload pod in the tenant namespace.

Findings not anchored to changed lines

These reference code outside this PR's diff (unchanged files, or lines outside a hunk), so GitHub cannot render them inline.

[MINOR] packages/apps/kubernetes-nodes/values.yaml:108 version's description still names talos.version as the only matrix counterpart

The @param {Version} version prose says the value "must satisfy the Talos<->Kubernetes support matrix against talos.version", but the render now also runs the matrix against the release osImage.builtin.version / osImage.factory.version resolves to (nodegroup.yaml:98-107).

cozyvalues-gen copies this prose verbatim into values.schema.json, the README table and the cozyrds openAPISchema the dashboard renders, so a regenerated schema does not make it current.

Not promoted: hack/check-worker-image-catalog-defaults.bats pins the shipped v1.13 row as a superset of the version enum, so no schema-valid pairing can reach that second call today. The sibling talos.version field was updated for exactly this and reads correctly.

[NIT] packages/apps/kubernetes-nodes/templates/talos-reconcile-job.yaml:387 $talosVersion is now dead

{{- $talosVersion := $.Values.talos.version }} has no reader left after line 463 switched $jobContext.talosVersion to $grpVersion. Harmless, but it reads as the value the Job uses and it is not.

characters and a label value stops at 63. */}}
annotations:
kubernetes-worker-image.cozystack.io/golden: {{ $goldenName | quote }}
kubernetes-worker-image.cozystack.io/pool: {{ printf "%s-%s" .clusterName .groupName | quote }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] the written .../pool annotation value is not pinned by any test, only the reader is

This line writes kubernetes-worker-image.cozystack.io/pool and line 169 reads that exact key back as the pool-identity conjunct of $poolCloned. The reader is covered: deleting the conjunct at 169 reddens the suite. The writer is not.

$ review-helper mutate . --mutations mutations.json --test 'cd packages/apps/kubernetes-nodes && helm unittest .'
  clone annotations: drop the pool annotation      GAP: the suite stayed green without the fix
  poolCloned: drop the pool-annotation conjunct    covered: the suite went red

Every fixture in worker_image_cloned_pool_test.yaml hardcodes kubernetes-myk8s-md0, so writer and reader are only related by hand. If they drift, the exemption stops matching and a pool that has already cloned loses its escape from the absence and Failed guards, which turns a documented degraded-but-alive state into a Failed HelmRelease for the whole pool. One assertion next to the existing golden one at tests/worker_image_source_test.yaml:202, on dataVolumeTemplates[0].metadata.annotations["kubernetes-worker-image.cozystack.io/pool"], closes it.

MINOR rather than MAJOR because the conjunct itself is covered and the failure is a future drift, not a live defect; reading the writer/reader pair as one guard makes MAJOR defensible.

{{- $createdAt := "" -}}
{{- range (dig "items" (list) ($classes | default dict)) -}}
{{- $annotations := dig "metadata" "annotations" (dict) . -}}
{{- if or (eq (dig "storageclass.kubernetes.io/is-default-class" "" $annotations | toString) "true") (eq (dig "storageclass.beta.kubernetes.io/is-default-class" "" $annotations | toString) "true") -}}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] the legacy beta default-StorageClass disjunct is unreachable from any fixture

The or here accepts both storageclass.kubernetes.io/is-default-class and storageclass.beta.kubernetes.io/is-default-class, and the comment above says why the beta one is there. Replacing that second term with false leaves the suite green, and grep -rn 'storageclass.beta.kubernetes.io' packages/apps/kubernetes-nodes/ returns only this template line, no fixture.

  defaultStorageClass: drop the beta annotation    GAP: the suite stayed green without the fix
  defaultStorageClass: drop newest-wins tiebreak   covered: the suite went red

On a cluster whose default class carries only the beta annotation, losing that term makes defaultStorageClassName return empty, $workerSC stays empty, and $workerSC $goldenSC is false, and the cross-class comparison skips. That is the corner the resolver was added to close. Add a third case to worker_image_two_default_classes_test.yaml with a single class carrying only the beta annotation.

{{- if not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" ($schematicID | toString)) -}}
{{- fail (printf "nodeGroup %q: Talos schematicID %q is not a plain lowercase alphanumeric identifier. It is interpolated into the worker DataVolume name and the image URL, so it must carry no whitespace, path separators or YAML metacharacters." .groupName ($schematicID | toString)) -}}
{{- end -}}
{{- if not (regexMatch "^v[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9a-z.]+)?$" ($version | toString)) -}}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] new format guards fail-close on talos.* values the API schema still accepts and the base still rendered

The three regexMatch checks at 264, 267 and 270 run for every pool, including one with osImage omitted, so they apply to values a running cluster may already hold. The cozyrds openAPISchema types all three as bare string with no pattern, so none is rejected at admission. Rendered both sides:

  version=v1.13            [base] OK   [head] FAIL: Talos version "v1.13" is not a vMAJOR.MINOR.PATCH release
  version=1.13.6           [base] OK   [head] FAIL
  version=v1.13.6-BETA     [base] OK   [head] FAIL
  schematic=ABC123         [base] OK   [head] FAIL: not a plain lowercase alphanumeric identifier
  schematic=abc_123        [base] OK   [head] FAIL
  factoryURL=no-scheme     [base] OK   [head] FAIL: not a plain http(s) URL
  version=v1.14.0-alpha.1  [base] OK   [head] OK
  factoryURL=trailing-slash[base] OK   [head] OK

Every rejected value already produces an unbootable worker on the base, so the guard is an improvement in signal, and no real Talos release or Factory schematic trips it. What changes is the shape of the failure for a pool sitting at the default minReplicas: 0: today its HelmRelease is Ready with zero workers and a typo nobody noticed, after this it is Failed, which also blocks unrelated edits to replicas, kubelet tuning and MHC timeouts on that pool. Either mirror the three patterns into values.schema.json and the cozyrds openAPISchema so the rejection lands on the CR the operator holds, or name the newly-refused shapes in the release note.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Follow-up on this one, because the example I gave is the weakest one available and it invites the obvious rebuttal: the pool was already broken, so nothing regressed. There is a case with no such defence.

A pool on osImage.builtin never reads imageFactoryURL. It clones the golden PVC, and I rendered it to check: the DataVolume carries source.pvc with no source.http at all, which is what the field's own description in values.schema.json already says. resolveOsImage validates it anyway, because $factoryURL is assigned before the branch and the builtin arm never reassigns it, so the third regexMatch runs on a field this path does not consume.

imageFactoryURL: "" is schema-valid: a bare string, no pattern, no minLength. Rendered at merge-base and at head against the chart's own kubernetesProvider fixture:

[base] talos.version=v1.13                            OK
[head] talos.version=v1.13                            FAIL: Talos version "v1.13" is not a vMAJOR.MINOR.PATCH release
[head] osImage.builtin + imageFactoryURL="not a url"  FAIL: imageFactoryURL "not a url" is not a plain http(s) URL
[head] osImage.builtin + imageFactoryURL=""           FAIL: imageFactoryURL "" is not a plain http(s) URL
[head] osImage.builtin: source.pvc set, source.http unset   passed

The operator this lands on is the one the feature is for. Somebody moving to osImage.builtin to stop depending on the Factory is exactly the person likely to blank imageFactoryURL, and they get a render failure on a field their pool does not use, with a message telling them it is interpolated into a source URL this path never builds.

Still MINOR from me. builtin is new, so no existing install breaks; it fails loudly on the first apply rather than silently; and leaving the default is a one-line workaround. Scoping the URL check to the paths that read it, the factory arm and the no-osImage default, would close it.

{{- if $resolvedFromDefault }}
{{- $defaultNote = printf " An empty storageClass was resolved to the cluster default StorageClass %q." $defaultSC }}
{{- end }}
{{- fail (printf "nodeGroup %q: storageClass %q differs from golden image %q storageClass %q — CDI cannot CSI-clone across StorageClasses and would fall back to a host-assisted copy over the pod network.%s Neither side can be moved in place: this golden's class is fixed for its lifetime, because a DataVolume spec cannot be patched, and the pool's storageClass is immutable on an existing pool. So add a NEW kubernetes-worker-image catalog entry with a different (schematicID, version) on storageClass %q (kubernetesWorkerImage.images in the cozystack-platform Package values) and point this pool's osImage.builtin at it; or, once no pool clones this golden, delete it and let the catalog recreate it on that class. Switching this pool to osImage.factory also resolves it." .groupName $workerSC $goldenName $goldenSC $defaultNote $workerSC) }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] the second remedy in the cross-class message does not do what it says

The message offers "once no pool clones this golden, delete it and let the catalog recreate it on that class". Deleting the golden alone makes the catalog recreate it from an unchanged images[] entry, so it comes back on the same class. The operator also has to edit the entry's storageClass, and doing that first trips the drift guard at packages/system/kubernetes-worker-image/templates/dv.yaml:58, which fails the catalog HelmRelease until the delete lands. The sequence that works is delete the golden, then edit the entry. The first remedy in the same message (a new (schematicID, version) entry) is correct as written, so this only misleads an operator who picks the second.

{{- $drift := list }}
{{- if and $liveURL (ne $liveURL ($url | toString)) }}{{- $drift = append $drift (printf "source URL %q -> %q" $liveURL $url) }}{{- end }}
{{- if and $liveStorage (ne $liveStorage ($storage | toString)) }}{{- $drift = append $drift (printf "storage %q -> %q" $liveStorage $storage) }}{{- end }}
{{- if and $liveStorageClass (ne $liveStorageClass ($storageClass | toString)) }}{{- $drift = append $drift (printf "storageClass %q -> %q" $liveStorageClass $storageClass) }}{{- end }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] the drift guard is blind exactly when the live golden carries no storageClassName

{{- if and $liveStorageClass (ne $liveStorageClass ($storageClass | toString)) }} skips whenever the live golden has no spec.storage.storageClassName. A golden created while its entry had storageClass: "" stores nothing there, so setting a class on that entry afterwards renders the patch this guard exists to catch, and CDI answers with the opaque "Cannot update DataVolume Spec" the guard was written to replace.

The premise is sound, verified against the pinned CDI: pkg/apiserver/webhooks/datavolume-validate.go:470-476 at kubevirt/[email protected] rejects any spec change outside the multi-stage checkpoint case. The shipped default is storageClass: "replicated" (values.yaml:76), so reaching this needs an operator to have set the class empty first. storage and the source URL do not have the same hole because the chart always renders them.

{{- $goldenStorage = dig "spec" "resources" "requests" "storage" "" $goldenSizePVC }}
{{- end }}
{{- end }}
{{- if $goldenStorage }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[NIT] the if $goldenStorage presence term is still unasserted, carried from my last round

The size-floor block behind it is reached and asserted, but nothing pins the skip. Forcing the term open leaves the suite green; forcing it shut reddens two cases, so only one direction is covered:

$ sed -i '' '281s/if \$goldenStorage/if true/'  .../nodegroup.yaml && helm unittest .
Tests:       124 passed, 124 total
$ sed -i '' '281s/if \$goldenStorage/if false/' .../nodegroup.yaml && helm unittest .
Tests:       2 failed, 122 passed, 124 total

The fallback added in 35d7a3a7 means a golden reaches the empty case only when neither its DataVolume spec nor its bound PVC states a size, which is narrower than it was, so this stays a NIT. A fixture with a golden in that state, asserting the pool still renders, closes it.

…hat read it

resolveOsImage assigns $factoryURL before it branches, and the builtin
arm never reassigns it, so the http(s) pattern ran for every pool
including one that clones. A builtin pool builds no source URL at all:
its DataVolume carries source.pvc and no source.http, and the reconcile
Job takes only the schematic and the version. The value was therefore
refused on a path that does not consume it.

The operator this landed on is the one the feature exists for. Somebody
moving to osImage.builtin to stop depending on the Image Factory is
exactly the person likely to blank imageFactoryURL, and the render then
failed with a message naming a source URL that path never builds. The
field is a bare string in values.schema.json and in the cozyrds
openAPISchema -- no pattern, no minLength -- so an empty or malformed
one reaches the render rather than being rejected at admission.

The guard is unchanged for osImage.factory and for the no-osImage
default, which are the two arms that interpolate it. Three cases: a
builtin pool renders with the field blanked and with it malformed, and
the omitted-osImage default still refuses a malformed one, so neither
new case can be read as relaxing the arm a running cluster is already
on. Forcing the new condition open reddens the two builtin cases;
forcing it shut reddens the factory and default ones.

Reviewed as [MINOR] on #3294.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
The three format guards run for every pool, including one with osImage
omitted, so they apply to values a running cluster may already hold. The
schema does not stop any of them: values.schema.json and the cozyrds
openAPISchema type talos.version, talos.schematicID and
talos.imageFactoryURL as bare strings with no pattern, so the CR accepts
a malformed one and the pool's HelmRelease is what fails.

Reproduced on a live stand rather than argued from the template. A
KubernetesNodes carrying talos.version "v1.13" was admitted with the
value intact and its HelmRelease went InstallFailed naming the format.
Editing an unrelated field on that pool afterwards -- maxReplicas 1 to 3
-- was accepted by the CR and reached .spec.values of the HelmRelease,
which stayed InstallFailed: the edit is taken everywhere and applied
nowhere. That is the cost being documented, and it is why the shapes are
named in prose where an operator reads them.

Pattern is not expressible here. cozyvalues-gen writes types.go,
values.schema.json, the README table and the cozyrd from values.yaml,
and its annotation set has no pattern; a pattern added to
values.schema.json by hand is erased by the next make generate, which is
how this was checked. Teaching the generator that annotation is the
follow-up that would let the rejection land on the CR instead.

schematicID already documented its shape. version and imageFactoryURL
now do too, the latter noting that a builtin pool builds no source URL
and so is not held to it.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Sergei Makarov (@SerjioTT) this is tracked in #3950 already, so pool side default goes there, not into this PR.

Your create-time point is what #3950 is missing. Proposed direction there is a render-time _cluster fallback, and lexfrei's comment on it fixes the precedence trap that today keeps such a default from winning at all, so once that is fixed the first render after an operator sets the mirror puts a new url inside the hashed block (nodegroup.yaml:89, hash at :477) and every pool that never set talos.imageFactoryURL per-CR rolls its workers. Same hazard you describe for osImage, on a knob that has nothing to do with goldens. Worth putting your comment on #3950 as it is.

Write path has a seam in flight already: #3956 hooks normalizeSpecNulls into Create and Update in rest.go, so create-time defaulting can sit next to it, Create only.

Scope of this PR stays as is, catalog opt-in on both levels.

@myasnikovdaniil
myasnikovdaniil merged commit 3748352 into test/drop-ghcr-mirror Sep 7, 2026
42 checks passed
@myasnikovdaniil
myasnikovdaniil deleted the feat/kubernetes-golden-worker-image branch September 7, 2026 12:47
myasnikovdaniil added a commit that referenced this pull request Sep 9, 2026
…image (CDI clone)

Rebase of #3294 onto main after the Phase 2 KubernetesNodes split (#3315).
The worker disk source and the in-guest installer pin move from
packages/apps/kubernetes to packages/apps/kubernetes-nodes, where the pool
objects now live; the per-node-group `nodeGroups.<name>.image` union becomes
the pool-level `image` value of the KubernetesNodes chart.

Adds packages/system/kubernetes-worker-image, an opt-in catalog that imports a
golden Talos worker image into cozy-public once per (schematicID, version). A
pool that sets image.builtin CDI-clones that golden instead of streaming the
raw image over HTTP per worker, which is the per-worker Image Factory
dependency behind the kubernetes-* node-join flake (#3231). image.factory
keeps the HTTP path and lets a pool point at its own mirror/schematic/version.
With `image` omitted the render is byte-identical to before -- the stored
render snapshot is unchanged, so existing workers do not roll on upgrade.

The e2e suites switch to the clone path: the per-worker talos-image-cache
mirror and its manifest, helper and tests are removed, the catalog is enabled
in the sandbox, and the run waits for the golden to import before creating the
tenant. Neither tenant CR carries a spec.talos override any more. The ghcr.io
pull-through that used to share that block was already removed with the QEMU
merge-gating lane (#4020), so both CRs now take the chart defaults.

Two things the cache took with it. The successful-join timing report loses its
cache-transfer arm: a cloned disk is populated in the storage layer, so there
is no compressed-byte service time to report per worker, and DataVolume
Pending -> Succeeded is the whole of the disk's own cost. And the node-join
failure path loses the cache re-probe, which was the collector its diagnostic
budget gave up first; what replaced it, the golden's own DataVolume, is read
inside the budget at (a2) rather than at its mercy.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
myasnikovdaniil added a commit that referenced this pull request Sep 9, 2026
…source

Five findings from the #3294 review rounds, each with a guard that fails
without the fix.

[MAJOR] The Talos<->Kubernetes support matrix keyed only on talos.version, so
a pool could keep a supported talos.version and still boot a Talos release
outside the kubelet's support window through image.builtin.version or
image.factory.version, with no render failure. The matrix moves into a named
template in _helpers.tpl and both paths consult it -- the pool default at the
top of nodegroup.yaml, the resolved version where the image is resolved. A
Talos minor the table does not list still passes, unchanged, because failing
closed on a hand-maintained table would block an operator moving to a newer
Talos before the table is updated.

[MINOR] schematicID, version and imageFactoryURL reach an unquoted YAML scalar
in the KubevirtMachineTemplate, and a value carrying a newline or a YAML
metacharacter injects keys into the pool's own template. The schema cannot
narrow them -- the pinned cozyvalues-gen derives `pattern` from the value type
and has no annotation for a custom one -- so they are pattern-checked at
render, which is where this chart already validates the kubelet reservation
fields against the same hazard. The clone-path DataVolume name is quoted too;
the HTTP url deliberately is not, because quoting it changes the KMT content
hash and rolls every live worker.

[MINOR] A golden was referenced only from inside a VM dataVolumeTemplates
source, so nothing told an operator which pools still pinned it -- and a pool
on `builtin: {}` does not name it in its own CR either. Every cloned worker
disk now annotates its source golden, and the catalog documents the one read
that answers "is this golden still in use".

[MINOR] An images[] entry is effectively immutable once its golden exists: the
DataVolume is named from (schematicID, version) while storage, storageClass
and the URL are rendered into a spec that cannot be patched in place. The
catalog said the opposite, inviting a `storage` bump. It now says to add a new
entry instead.

[MAJOR, residual] Nothing tied the parent kubernetes chart's talos defaults to
kubernetes-nodes'. Both declare "keep in sync", the pool chart's `make update`
copies neither, and the catalog drift guard reads only one of the two files. A
one-sided bump now trips three checks instead of none.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
myasnikovdaniil added a commit that referenced this pull request Sep 9, 2026
…hat read it

resolveOsImage assigns $factoryURL before it branches, and the builtin
arm never reassigns it, so the http(s) pattern ran for every pool
including one that clones. A builtin pool builds no source URL at all:
its DataVolume carries source.pvc and no source.http, and the reconcile
Job takes only the schematic and the version. The value was therefore
refused on a path that does not consume it.

The operator this landed on is the one the feature exists for. Somebody
moving to osImage.builtin to stop depending on the Image Factory is
exactly the person likely to blank imageFactoryURL, and the render then
failed with a message naming a source URL that path never builds. The
field is a bare string in values.schema.json and in the cozyrds
openAPISchema -- no pattern, no minLength -- so an empty or malformed
one reaches the render rather than being rejected at admission.

The guard is unchanged for osImage.factory and for the no-osImage
default, which are the two arms that interpolate it. Three cases: a
builtin pool renders with the field blanked and with it malformed, and
the omitted-osImage default still refuses a malformed one, so neither
new case can be read as relaxing the arm a running cluster is already
on. Forcing the new condition open reddens the two builtin cases;
forcing it shut reddens the factory and default ones.

Reviewed as [MINOR] on #3294.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
myasnikovdaniil added a commit that referenced this pull request Sep 9, 2026
…image (CDI clone)

Rebase of #3294 onto main after the Phase 2 KubernetesNodes split (#3315).
The worker disk source and the in-guest installer pin move from
packages/apps/kubernetes to packages/apps/kubernetes-nodes, where the pool
objects now live; the per-node-group `nodeGroups.<name>.image` union becomes
the pool-level `image` value of the KubernetesNodes chart.

Adds packages/system/kubernetes-worker-image, an opt-in catalog that imports a
golden Talos worker image into cozy-public once per (schematicID, version). A
pool that sets image.builtin CDI-clones that golden instead of streaming the
raw image over HTTP per worker, which is the per-worker Image Factory
dependency behind the kubernetes-* node-join flake (#3231). image.factory
keeps the HTTP path and lets a pool point at its own mirror/schematic/version.
With `image` omitted the render is byte-identical to before -- the stored
render snapshot is unchanged, so existing workers do not roll on upgrade.

The e2e suites are not switched onto this path, and the reason is the
substrate rather than the feature. A clone is a storage-layer copy, so it needs
a StorageClass whose volumes are reachable from any node; the merge-gating
lanes run on Talos containers since #4020, which share the runner kernel and
cannot load DRBD, leaving node-local `local` as the only class there. So the
suites keep importing over HTTP, `osImage` omitted, which is also the render
the no-roll guarantee rests on. Nightly and the tag build run the same suites
on QEMU with DRBD and `replicated`, which is where wiring up clone coverage
belongs -- as its own change, with that lane's storage headroom measured.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
myasnikovdaniil added a commit that referenced this pull request Sep 9, 2026
…source

Five findings from the #3294 review rounds, each with a guard that fails
without the fix.

[MAJOR] The Talos<->Kubernetes support matrix keyed only on talos.version, so
a pool could keep a supported talos.version and still boot a Talos release
outside the kubelet's support window through image.builtin.version or
image.factory.version, with no render failure. The matrix moves into a named
template in _helpers.tpl and both paths consult it -- the pool default at the
top of nodegroup.yaml, the resolved version where the image is resolved. A
Talos minor the table does not list still passes, unchanged, because failing
closed on a hand-maintained table would block an operator moving to a newer
Talos before the table is updated.

[MINOR] schematicID, version and imageFactoryURL reach an unquoted YAML scalar
in the KubevirtMachineTemplate, and a value carrying a newline or a YAML
metacharacter injects keys into the pool's own template. The schema cannot
narrow them -- the pinned cozyvalues-gen derives `pattern` from the value type
and has no annotation for a custom one -- so they are pattern-checked at
render, which is where this chart already validates the kubelet reservation
fields against the same hazard. The clone-path DataVolume name is quoted too;
the HTTP url deliberately is not, because quoting it changes the KMT content
hash and rolls every live worker.

[MINOR] A golden was referenced only from inside a VM dataVolumeTemplates
source, so nothing told an operator which pools still pinned it -- and a pool
on `builtin: {}` does not name it in its own CR either. Every cloned worker
disk now annotates its source golden, and the catalog documents the one read
that answers "is this golden still in use".

[MINOR] An images[] entry is effectively immutable once its golden exists: the
DataVolume is named from (schematicID, version) while storage, storageClass
and the URL are rendered into a spec that cannot be patched in place. The
catalog said the opposite, inviting a `storage` bump. It now says to add a new
entry instead.

[MAJOR, residual] Nothing tied the parent kubernetes chart's talos defaults to
kubernetes-nodes'. Both declare "keep in sync", the pool chart's `make update`
copies neither, and the catalog drift guard reads only one of the two files. A
one-sided bump now trips three checks instead of none.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
myasnikovdaniil added a commit that referenced this pull request Sep 9, 2026
…hat read it

resolveOsImage assigns $factoryURL before it branches, and the builtin
arm never reassigns it, so the http(s) pattern ran for every pool
including one that clones. A builtin pool builds no source URL at all:
its DataVolume carries source.pvc and no source.http, and the reconcile
Job takes only the schematic and the version. The value was therefore
refused on a path that does not consume it.

The operator this landed on is the one the feature exists for. Somebody
moving to osImage.builtin to stop depending on the Image Factory is
exactly the person likely to blank imageFactoryURL, and the render then
failed with a message naming a source URL that path never builds. The
field is a bare string in values.schema.json and in the cozyrds
openAPISchema -- no pattern, no minLength -- so an empty or malformed
one reaches the render rather than being rejected at admission.

The guard is unchanged for osImage.factory and for the no-osImage
default, which are the two arms that interpolate it. Three cases: a
builtin pool renders with the field blanked and with it malformed, and
the omitted-osImage default still refuses a malformed one, so neither
new case can be read as relaxing the arm a running cluster is already
on. Forcing the new condition open reddens the two builtin cases;
forcing it shut reddens the factory and default ones.

Reviewed as [MINOR] on #3294.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
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 area/storage Issues or PRs related to storage (linstor, seaweedfs, bucket, velero, harbor) kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants