feat(kubernetes): boot tenant worker disks from a shared golden Talos image (CDI clone) - #3294
Conversation
Summary of ChangesHello, 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
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 AssistThe 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
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 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
|
There was a problem hiding this comment.
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" }} |
There was a problem hiding this comment.
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 }}There was a problem hiding this comment.
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.
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe 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. ChangesGolden Talos worker image provisioning
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
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
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
packages/system/kubernetes-worker-image/templates/dv.yaml:32— the| default "4Gi"tail contradicts the documented ~4.15Gi minimum; if$.Values.storageis ever cleared it renders a too-small PVC (DataVolume too small). Make the final fallback6Giand quote it (already flagged by the Gemini bot).packages/apps/kubernetes/templates/cluster.yaml:127—$useCloneis 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 reachesSucceeded, or indefinitely if the golden import fails. Consider gating ondig "status" "phase" "" $goldenDV | eq "Succeeded".- Cross-StorageClass clone: if a tenant's worker
storageClassdiffers from the golden's (replicated), CDI cannot CSI-clone and silently falls back to a host-assisted network copy — documented invalues.yamlbut unguarded. Fine to leave, worth a note.
3f3dd13 to
f3589e4
Compare
|
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:
|
ba1ab25 to
80c2c2c
Compare
IvanHunters
left a comment
There was a problem hiding this comment.
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.batsare replaced by the golden clone (hack/e2e-chainsaw/_lib/run-kubernetes.sh:334-339setsimage.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
mainand 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.
|
Aleksei Sviridkin (@lexfrei) follow-ups 2 and 3 are now closed in B1 — opt-in inverted. Correct, and the description and release note were wrong. Both now state opt-in via B2 — e2e never enables the golden. Fixed: B3 — untested lookup pin. Removed rather than tested: the disk-system source now derives purely from Follow-up 1 — 4Gi fallback. Fixed: 6Gi, quoted. Follow-up 2 — readiness vs existence. Fixed in Follow-up 3 — cross-StorageClass. Fixed in 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: |
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]>
`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]>
|
IvanHunters both MAJORs are fixed — thanks, the first one caught something I had actively made worse. MAJOR 1 — fixed in Goldens now carry The render also no longer aborts on "golden not ready", only on I did keep the MAJOR 2 — fixed in 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 One precision on "no existing test catches the drift": a catalog-side bump is caught today, by the pinned golden name in 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 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 |
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]>
5952c7d to
ea30f79
Compare
…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
left a comment
There was a problem hiding this comment.
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
Failedfinding above.resource-policy: keepplus the operator queries invalues.yaml:40-55make 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
builtinworker disk in the cluster becomes a CSI clone of one source PVC incozy-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-68asserts, the golden's import against a real factory, LINSTOR clone placement and concurrency, and Server-Side Apply behaviour when the newimagekey first reaches a liveKubernetesNodes. The "verified on dev10" note atnodegroup.yaml:228-229is the author's observation, not something this review reproduced. - The no-roll claim was verified rather than taken.
tests/__snapshot__/render_snapshot_test.yaml.snapis untouched by the diff and its 12 snapshots pass, and a base-versus-head render diff withimageomitted came back byte-identical across six value sets (defaults,talos.registryMirrors, a GPU pool,kubeletpluslogSerialConsole, thepodCpuLimit/podCpuRequestpair, andtalos.version: v1.13.7), with the content-hash KubevirtMachineTemplate name holding atkubernetes-myk8s-md0-a6ac3e.instanceType-sized pools are not covered by the snapshot because that branch resolves throughlookup; thedisk-systemblock 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-46bindsdatavolumes/sourcecreate forsystem:serviceaccountsincozy-public), so no new RBAC is needed; the cold-install ordering reaches the namespace creator transitively,kubernetes-worker-imagetocozystack.kubevirt-cditocozystack.cozystack-basics, matchingvm-default-images; the new golden does not appear in the tenant vm-disk source dropdown, becauseSourceField.tsx:7,39filterscozy-publicPVCs on thevm-default-images-prefix; the reconcile Job is content-hash named attalos-reconcile-job.yaml:478-484, so animage.*.versionchange produces a new Job and the in-guest installer really moves; the installer-registry asymmetry is disclosed twice, atvalues.yaml:122andvalues.yaml:135, so an air-gapped pool is told it must also pointtalos.installerRepository; the new bats file is picked up by thehack/*.batsglob (Makefile:161) and the chart'stesttarget byhack/helm-unit-tests.sh, both green (6 and 5 tests, and 102 helm-unittest cases inkubernetes-nodes);go build ./...andgo veton the touched packages are clean andzz_generated.deepcopy.gois regenerated; the new render-time regexes reject only values that could not have worked before; the cozyrds diff adds only theimageproperty with no change toresourceNames, secrets, services or any RBAC surface; an empty or nullimagescatalog 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.componentsbeing 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 pinningimage.factory.imageFactoryURLto 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.shno longer emits aspec.talosblock 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
builtinpool, 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-imagesand the other nine packages registered throughcozystack.platform.package.optionalshare 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-4677reports nothing when the outertimeout 12mfires before the innerkubectl waitstarts, 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" $) }} |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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) }} |
There was a problem hiding this comment.
[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 }} |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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 }} |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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]>
…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]>
51dfcef to
c0ac27c
Compare
…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
left a comment
There was a problem hiding this comment.
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.optionaltakes a components argument and emits the block;iaas.yaml:147-149forwards.Values.kubernetesWorkerImage, declared atpackages/core/platform/values.yaml:562. Rendering the platform with the package enabled and three overrides set put all three underspec.components.kubernetes-worker-image.values. The chart's own repository template needs a liveOCIRepository, 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 storagefor each worker cloning at that moment", plus the failure shape, in bothkubernetes-worker-image/values.yaml:78andkubernetes-nodes/values.yaml:60, and through cozyvalues-gen into the schema and the cozyrdsopenAPISchema. - The package header points at
KubernetesNodesand names the parent CR field as accepted-but-inert;tests/dv_test.yamlnames 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, anddv.yaml:36-59adds 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 bothnodegroup.yaml:96andtalos-reconcile-job.yaml:451. Behaviour-preserving: boot URL, installer image and golden name are identical to the previous head across four override shapes (builtin {},builtinwith a version,factorywith a version,factorywith a schematic and a mirror URL). imagerenamed toosImage. The field does not exist at the base (osImage: 0 hits in the basevalues.yaml, noOSImageorjson:"image"in the base types), so this is an internal rename and not a CR contract change, and it propagated tovalues.schema.json,README.md,types.go,zz_generated.deepcopy.go, the cozyrdsopenAPISchemaand itskeysOrder, the e2e and the bats guard.- ComputePlane records the decision:
osImageis 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
untilcondition so a transientgetcannot 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,kubeletpluslogSerialConsole,talos.version: v1.13.7), and the snapshot fixtures are untouched by the diff.
Checked and found sound
$poolClonedis a real test, not a proxy: it matches a DataVolume in the release namespace on both the pool and golden annotations and onstatus.phase: Succeeded, and the annotations are emitted atnodegroup.yaml:272-273. The pool-exact annotation rather than a name prefix is the right call, for the reason the comment gives aboutmd0andmd0-gpu.- I expected the
$poolClonedgate 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.defaultStorageClassNamehandles 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 ./...andgo vet ./api/...are clean; the generatedvalues.schema.json,README.mdandzz_generated.deepcopy.goall 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 namedcomputeplane, and thekubernetes-nodesentry is aninstanceTypelookupthat is empty underhelm templateand fails identically at the base (nodegroup.yaml:303there,:513here). The sixmissing_refsare platform-injected values, absent at the base too. - Both
template-comment-bloatflags 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-driftflag on the top-levelversionfield is stale: the caveat about unlisted Talos minors belongs on the field that carries the override, andtalos.versionnow has it. - 117 helm-unittest cases and 12 snapshots green in
kubernetes-nodes, 14 inkubernetes-worker-image, 6 bats checks green.shellcheckreports no errors, one warning (SC2034, an unusedportatrun-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
osImagekey first reaches a liveKubernetesNodes. The two guards that depend onlookupare inert underhelm templateby construction, so the drift guard atdv.yaml:36-59and 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
kubernetesProviderfixture, which mockslookupresponses. 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-mirrorlanding 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) }} |
There was a problem hiding this comment.
[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) }} |
There was a problem hiding this comment.
[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 }} |
There was a problem hiding this comment.
[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]>
|
My previous comment answered your first round and crossed with this one, sorry for the noise. This is the reply to the round at All three fixed in 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 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 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
|
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. The consuming half has no equivalent lever. 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 A create-time default does not have that property. A platform-level setting stamped into the spec when a The wrong version of this is worth naming explicitly, because it looks like the easy answer: a 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 |
IvanHunters
left a comment
There was a problem hiding this comment.
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-classfailed 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$poolClonedalone 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.
$goldenStoragenow 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.../poolannotation 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 ontalos.*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 namestalos.versionas the only matrix counterpart - [NIT]
packages/apps/kubernetes-nodes/templates/talos-reconcile-job.yaml:387,$talosVersionis now dead - [NIT]
packages/apps/kubernetes-nodes/templates/nodegroup.yaml:281, theif $goldenStoragepresence 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 templateof 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 cornerstalos.version=v1.13.5,talos.imageFactoryURL=<mirror>,storageClass="",instanceType=u1.medium,talos.registryMirrors,version=v1.34. The content-hashed KMT stayskubernetes-myk8s-md0-2d362eforosImageomitted,osImage: {}andosImage.factory: {}, while the pre-existing mutable fields already re-hash it (talos.versiongivesfdeb88,diskSizegives6d9b26). That is also the answer toosImagebeing 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 invalues.yaml,values.schema.json, the cozyrdsopenAPISchema, the README and the Go doc comment. - Verified: the platform helper change has no blast radius on its nine other callers. Rendering
packages/core/platformat merge-base and at head onvalues.yaml,values-isp-full.yamlandvalues-isp-hosted.yamldiffers only by the addedPackageSource; no existingPackageCR changes, and none is emitted for the new package unless it is inenabledPackages. - Verified: the pieces the clone depends on exist.
datavolumes/sourceincozy-publicis already granted tosystem: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-26is the in-tree precedent for the samesource.pvcshape out ofcozy-public, and thedataVolumeTemplatesannotations survive both hops (CAPK v0.1.10pkg/kubevirt/utils.go:70-73deep-copies the template spec and only renames, KubeVirt v1.8.4pkg/storage/types/dv.go:125clones the annotation map onto the created DataVolume). Flux's ServiceAccount is bound tocluster-admin(internal/fluxinstall/manifests/fluxcd.yaml:7831-7847), so thelookupcalls intocozy-publicand the cluster-scoped StorageClass list resolve under helm-controller. - Mechanical sweep run on the added shell (
hack/check-worker-image-catalog-defaults.bats, the changedhack/e2e-chainsaw/_lib/run-kubernetes.sh,hack/e2e-install-cozystack.bats,hack/e2e-prepare-cluster.bats). Eleven|| true/2>/dev/nullhits, 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.shellcheckon 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:95raises 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 plusdiskSize + golden storageper concurrent clone, multiplied by the replica count of a replicated class, andvalues.yamlsays 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 indiskSizeand in the catalog'sstoragedescription, and reversible by flippingosImageback. - 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-latestandkubernetes-previoussuites and gates the pool on the golden reachingSucceededfirst, which is the right coverage for it, but I have not observed that run. - Not exercised:
isp-hosted. ThePackageSourcerenders there but thePackagedoes not, because the iaas bundle is off on that preset. That matcheskubernetes-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.yamlcarries 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-teston a disposable dev cluster for the builtin path specifically: create the catalog, wait for the golden, create a pool onosImage.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 }} |
There was a problem hiding this comment.
[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") -}} |
There was a problem hiding this comment.
[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)) -}} |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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) }} |
There was a problem hiding this comment.
[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 }} |
There was a problem hiding this comment.
[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 }} |
There was a problem hiding this comment.
[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]>
|
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 Write path has a seam in flight already: #3956 hooks Scope of this PR stays as is, catalog opt-in on both levels. |
…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]>
…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]>
…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]>
…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]>
…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]>
…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]>
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-imageimports one golden DataVolume intocozy-publicper(schematicID, version). Disabled by default, enable by addingcozystack.kubernetes-worker-imagetobundles.enabledPackages. Its knobs (imageFactoryURL,storageClass,storage,images) are set throughspec.components.platform.values.kubernetesWorkerImageon 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.KubernetesNodesgetsosImagefield with two mutually exclusive forms:osImage.builtinboots 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.factorykeeps HTTP import and lets pool pin ownimageFactoryURL,schematicIDandversion. This is bring-your-own path for operator running own factory or caching mirror, and it is what pool gets whenosImageis 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-cacheDeployment 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 hostkvm_amdvgifflag, 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
osImageomitted renderedKubevirtMachineTemplateis 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
Failedphase. PoolstorageClassdifferent 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 transferosImage.builtinremoves. Empty class on either side resolves against cluster default class before comparing, so pool atstorageClass: ""is checked too.diskSizebelow golden storage, because clone target cannot be smaller than source. Talos release the matrix lists as unsupported, whether pool reached it throughtalos.versionor throughosImage.*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
Failedgolden 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 tostorage,storageClassor 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-gpuproduces disks carryingmd0prefix.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 unchangedmake unit-tests: 82 charts, 1742 helm-unittest cases, 1485 bats cases, exit 0helm 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 inhack/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 writtenkubernetes-worker-image.cozystack.io/poolannotation leaves the suite green (only the reader is asserted), replacing the legacystorageclass.beta.kubernetes.io/is-default-classdisjunct withfalseleaves it green, and forcing theif $goldenStoragepresence 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 behaviourgo buildandgo veton root andapi/apps/v1alpha1make generateandpre-commitcleanVerified 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,
replicatedStorageClass) installed from this branch'spackages/tree. This covers what neitherhelm templatenor the unit suites can answer.bundles.enabledPackagesalone:PackageSource cozystack.kubernetes-worker-imagereconciled, its HelmRelease installed, and the goldentalos-worker-<schematic>-v1.13.6imported toSucceededin 108s (6Gi, RWX,replicated).osImage.builtinclones it on the storage layer. Both worker DataVolumes wentCSICloneInProgressthenSucceededin 43s withRunning=False reason=Completed message="Clone Complete", carryingsource.pvcand nosource.http, and no importer or upload Pod appeared in the tenant namespace.Running/Ready, joined the tenant cluster and wentReadyabout 66s after creation, and the tenant's cilium, coredns, csi, ingress-nginx and metrics-server addons all installed on them.ReadWriteMany6Gi to workerReadWriteMany20Gi on the samereplicatedclass, grown by CDI.kubernetes-worker-image.cozystack.io/goldennaming the golden and.../poolnamingkubernetes-wimg-md0.KubevirtMachineTemplatename unchanged and the VMI and DataVolume UIDs identical, so a reconcile does not roll the pool.create datavolumes --subresource=source -n cozy-publicresolves toyesfor a tenant ServiceAccount through thecdi-clone-dvRoleBinding, and CDI runs withcloneStrategyOverride: csi-clone.Both behavioural findings were reproduced on the same stand. A
KubernetesNodescarryingtalos.version: v1.13was admitted by the API with the value intact and its HelmRelease wentInstallFailednaming the format; a subsequent unrelated edit (maxReplicas1 to 3) was accepted by the CR and reached.spec.valuesof the HelmRelease while it stayedInstallFailed, so the edit is taken everywhere and applied nowhere. And a pool onosImage.builtinwithtalos.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