Describe the bug
ConvertHelmReleaseToApplicationWithMonitor calls r.applySpecDefaults(&app).
So every GET, LIST and WATCH returns the spec with the schema's defaults filled
in. Create does not apply them: the HelmRelease's spec.values are the spec
exactly as the client sent it.
Update starts from the defaulted object returned by r.Get, applies the
change and writes app.Spec as the new values. The first update to any
Application created with a partial spec therefore writes the defaults into
spec.values, even when the update only touches metadata. That bumps the
HelmRelease's generation, and Flux runs a Helm upgrade the user never asked for.
Any client that sends only the fields it sets is affected, including a
dashboard form or kubectl apply with a minimal manifest.
Environment
- Cozystack v1.6.2; the code path is unchanged on
main as of 2026-09-18 (8799a4e)
- Flux helm-controller v1.5.0
- Kubernetes v1.34.3, Talos v1.12.6
- Provider: Hetzner bare metal, single node
To Reproduce
- Create a
VMDisk with only spec.source.image.name and spec.storage.
- Record
kubectl -n <ns> get helmrelease vm-disk-<name> -o jsonpath='{.metadata.generation} {.spec.values}'.
- Patch only an annotation on the
VMDisk.
- Repeat step 2. The generation has gone up by one, and
spec.values has
gained the schema defaults.
Expected behaviour
A metadata-only change does not change the HelmRelease's values, and does not
upgrade the release.
Actual behaviour
The first write of any kind upgrades the release. For a VMInstance that
upgrade then hangs; see #3734.
It also freezes the application on today's defaults. Once the first update has
written them into spec.values, they are no longer defaults but values the user
appears to have set. A later chart release that changes a default never
reaches an application that has been written to even once, and nothing
distinguishes it from one where the user chose the old value. For example, open
PR #3936 moves RabbitMQ's schema default resourcesPreset from t1.nano to
s1.nano. A RabbitMQ created without resourcesPreset and later annotated,
relabelled or edited in any way now has resourcesPreset: t1.nano in its
values, and keeps it after #3936 ships. #3956 names this hazard as the reason
not to write schema defaults into the spec. Update does exactly that today,
silently, and only to applications that have been touched.
Logs
The HelmRelease spec.values before and after one annotation patch. Generation
went from 1 to 2 for both.
vm-disk-probe:
+ "optical": false,
"storage": "2Gi",
+ "storageClass": "replicated"
vm-instance-probe:
+ "cloudInit": "",
+ "cloudInitSeed": "",
+ "cpuModel": "",
+ "external": false,
+ "externalAllowICMP": true,
+ "externalMethod": "PortList",
+ "externalPorts": [22],
+ "gpus": [],
+ "networks": [],
+ "resources": {},
+ "runStrategy": "Always",
+ "subnets": []
Additional context
A possible fix, in Update: before writing spec.values, drop every field
that was absent from the stored values and whose incoming value equals the
default applySpecDefaults filled in on read. A field the client really changed
survives, a metadata-only update leaves the values byte-identical, and a default
stays a default. The reverse, applying defaults in Create so the two paths
agree, would remove the unexpected upgrade but freeze every application at
creation instead, for the reason #3956 gives.
Setting a field to exactly its current default then does not pin it. That seems
the right trade: it is what an absent field means already.
Checklist
Describe the bug
ConvertHelmReleaseToApplicationWithMonitorcallsr.applySpecDefaults(&app).So every GET, LIST and WATCH returns the spec with the schema's defaults filled
in.
Createdoes not apply them: the HelmRelease'sspec.valuesare the specexactly as the client sent it.
Updatestarts from the defaulted object returned byr.Get, applies thechange and writes
app.Specas the new values. The first update to anyApplication created with a partial spec therefore writes the defaults into
spec.values, even when the update only touches metadata. That bumps theHelmRelease's generation, and Flux runs a Helm upgrade the user never asked for.
Any client that sends only the fields it sets is affected, including a
dashboard form or
kubectl applywith a minimal manifest.Environment
mainas of 2026-09-18 (8799a4e)To Reproduce
VMDiskwith onlyspec.source.image.nameandspec.storage.kubectl -n <ns> get helmrelease vm-disk-<name> -o jsonpath='{.metadata.generation} {.spec.values}'.VMDisk.spec.valueshasgained the schema defaults.
Expected behaviour
A metadata-only change does not change the HelmRelease's values, and does not
upgrade the release.
Actual behaviour
The first write of any kind upgrades the release. For a
VMInstancethatupgrade then hangs; see #3734.
It also freezes the application on today's defaults. Once the first update has
written them into
spec.values, they are no longer defaults but values the userappears to have set. A later chart release that changes a default never
reaches an application that has been written to even once, and nothing
distinguishes it from one where the user chose the old value. For example, open
PR #3936 moves RabbitMQ's schema default
resourcesPresetfromt1.nanotos1.nano. A RabbitMQ created withoutresourcesPresetand later annotated,relabelled or edited in any way now has
resourcesPreset: t1.nanoin itsvalues, and keeps it after #3936 ships. #3956 names this hazard as the reason
not to write schema defaults into the spec.
Updatedoes exactly that today,silently, and only to applications that have been touched.
Logs
The HelmRelease
spec.valuesbefore and after one annotation patch. Generationwent from 1 to 2 for both.
vm-disk-probe:vm-instance-probe:Additional context
A possible fix, in
Update: before writingspec.values, drop every fieldthat was absent from the stored values and whose incoming value equals the
default
applySpecDefaultsfilled in on read. A field the client really changedsurvives, a metadata-only update leaves the values byte-identical, and a default
stays a default. The reverse, applying defaults in
Createso the two pathsagree, would remove the unexpected upgrade but freeze every application at
creation instead, for the reason #3956 gives.
Setting a field to exactly its current default then does not pin it. That seems
the right trade: it is what an absent field means already.
Checklist