cluster-controller drops unknown SGCluster fields on status compare-and-swap after an operator upgrade
Problem to solve
After a new StackGres version is deployed, the cluster-controller silently removes fields of existing SGCluster resources that its own build does not know about.
The compare-and-swap itself is correct — the controller re-reads the resource and locks on the fetched resourceVersion. The loss happens in the body of the write: the SGCluster CRD POJOs are annotated @JsonIgnoreProperties(ignoreUnknown = true) and have no additionalProperties catch-all, so any field present on the server but absent from the running build's model is dropped on the deserialize/serialize round trip and disappears from the object that is PUT back.
Current behaviour
Note on paths. This was reported against
main-1.18, and the paths below are that branch's. Onmainthe helper and its per-kind subclasses have been renamed from*Schedulerto*Writer(AbstractCustomResourceWriter.java:58-80,ClusterWriter) and the call sites moved toClusterControllerReconciliator.java:168and:173,ManagedSqlReconciliator.java:300,PatroniBackupFailoverRestartReconciliator.java:112. The code is otherwise unchanged and the defect is the same on both branches.
All four SGCluster writes in the cluster-controller go through the same compare-and-swap helper:
cluster-controller/.../ClusterControllerReconciliator.java:161—updateClusterPodStatus(status.arch,status.os,status.podStatuses)cluster-controller/.../ClusterControllerReconciliator.java:166— extension build sync (status.extensions[].build)cluster-controller/.../ManagedSqlReconciliator.java:300—status.managedSqlcluster-controller/.../PatroniBackupFailoverRestartReconciliator.java:112—status.replicationInitializationFailedSgBackup
common/.../resource/AbstractCustomResourceScheduler.java:58-80:
@Override
public T update(T resource, Consumer<T> setter) {
return KubernetesClientUtil.retryOnConflict(
() -> {
T resourceToUpdate = getCustomResourceEndpoints()
.inNamespace(...).withName(...).get(); // <-- GET, deserialize: unknown fields lost here
...
setter.accept(resourceToUpdate);
return getCustomResourceEndpoints()
.inNamespace(...).resource(resourceToUpdate)
.lockResourceVersion(resourceToUpdate.getMetadata().getResourceVersion())
.update(); // <-- full PUT replace of the whole object
});
}Two aggravating details:
.update()is a full PUT replace of the entireSGCluster, not a status-only write. TheSGClusterCRD declares only thescalesubresource (common/src/main/resources/crds/SGCluster.yaml:52-57), there is nostatussubresource, so.updateStatus()is not usable andspecis inside the blast radius as well — a controller that only means to writestatus.podStatusesrewritesspectoo.StackGresClusterSpecandStackGresClusterStatusare annotated@JsonInclude(JsonInclude.Include.NON_DEFAULT), so anything the model maps but leaves at its default value is also omitted from the serialized body.
The model:
// common/.../crd/sgcluster/StackGresCluster.java:28-45
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true) // <-- nothing captures unknown fields
...
public final class StackGresCluster
extends CustomResource<StackGresClusterSpec, StackGresClusterStatus>
implements Namespaced {@JsonIgnoreProperties is present on 64 of the 79 classes in io.stackgres.common.crd.sgcluster. None of them has an additionalProperties map or a @JsonAnySetter, and the fabric8 base class io.fabric8.kubernetes.client.CustomResource does not provide one either (unlike the generated built-in models such as ObjectMeta, which do have @JsonAnySetter + additionalProperties and therefore round-trip unknown content safely).
When it triggers
The cluster-controller runs as a sidecar inside the cluster Pods, with its image pinned at StatefulSet-generation time to the operator's own build (operator/.../sidecars/controller/SingleReconciliationCycle.java:74, StackGresModules.CLUSTER_CONTROLLER.getImageName()). Upgrading the operator does not restart the existing Pods — they keep the previous sidecar image until an explicit restart (e.g. an SGDbOps restart/securityUpgrade).
That leaves a window in which:
- the new operator has installed the new
SGClusterCRD, so the API server accepts and persists the newly added fields (CRD structural-schema pruning keeps them precisely because the new CRD declares them), and the new operator writes them; - every still-running Pod hosts an older cluster-controller whose POJOs do not model those fields;
- on the next reconciliation tick that old controller does GET → mutate status → PUT, and the new fields are gone.
The window can be long (it lasts until every Pod of every cluster has been restarted) and the controller reconciles continuously, so the loss is effectively immediate and repeated. Nothing reports it: the write succeeds, the CAS succeeds, no conflict, no event, no log line.
Proposal
Make the SGCluster model preserve fields it does not know about, so that a round trip through an older build is lossless:
-
Add a
@JsonAnySetter/@JsonAnyGetterbackedadditionalPropertiesmap to theSGClusterCRD POJOs, mirroring what the fabric8 built-in models already do. -
Leave
@JsonIgnorePropertiesalone. An earlier revision of this issue claimed theignoreUnknown = truehad to be removed for the catch-all to be reachable. That is wrong:BeanDeserializerBase.handleUnknownVanillaconsults the any-setter before_ignoreAllUnknown, so once a class has the catch-all the annotation is inert. Keeping it halves the change, leavesCrdIgnoreUnknownPropertiesTestand the sharedModelTestUtilhelper untouched, and preserves the safety net for the classes that cannot take the catch-all (below). Properties named in a@JsonIgnoreProperties(value = {...})list are still dropped before the any-setter, so the deliberate exclusions onServiceSpec,SecretKeySelectorand friends keep working. -
The REST API needs the same treatment.
AbstractResourceService.updatere-reads the custom resource, but the transformers then dotransformation.setSpec(mapper.convertValue(dto.getSpec(), <CRD spec>.class))— the wholespecsubtree is replaced from the DTO, and anything the DTO model does not declare was already dropped on the way out. So the web console strips unknown fields on every save, through a parallel model (io.stackgres.apiweb.dto.*) that does not inherit from the CRD POJOs. The DTOs need the same catch-all, and the transformers that rebuild the spec field by field instead of converting it (PostgresConfigTransformer,PoolingConfigTransformer) have to carry it over explicitly.
Implementation notes
- The whole model tree needs it, not just the root. Unknown fields only survive at the level
where a catch-all exists, so covering only
StackGresClusterwould still drop a newspec.pods.*field. Onmainthat is 279 classes underio.stackgres.common.crd.**(including.external) and 257 underio.stackgres.apiweb.dto.**. A single abstract base class that the classes without anextendsclause inherit from keeps this to one line plus one import per file; classes extending another project class inherit it transitively, and those extending a generated fabric8 model already have the map. @Buildable/ sundrio: the generatedXBuilder.build()callssetAdditionalProperties(Map)unconditionally, so that setter is mandatory and, because the models are built withlazyMapInitEnabled = false, it has to be null safe or every builder-constructed object ends up with a null map.- Jackson: the any-getter needs
@JsonIgnoreon it. Since jackson-databind#4775 (2.19) an any-getter also registers as a regular getter, so without it the map is additionally serialized under a literaladditionalPropertieskey. equals/hashCode: the models implement them explicitly and do not callsuper, so with a base class the map stays out of them. That is the wanted default — it keeps the operator's drift detection (DeployedResourcesCache) andConfigInstallerbehaving exactly as before.- Known gap: the 22 custom resource roots extend
io.fabric8.kubernetes.client.CustomResource, which has no catch-all and cannot be changed, so unknown root-level keys are still dropped. A CR root only holdsapiVersion,kind,metadata,specandstatus, all of them covered by the models they point at, so this is inert in practice. - Side effect on the published schema: models that declare their own
@JsonIgnoreProperties(value = {...})list end up showingadditionalPropertiesas a property of the OpenAPI schema the REST API publishes, which reachessg-swagger.yamland the admin UI tooltips. Measured against the base commit this goes from 8 schemas to 15. It is cosmetic — only the published schema is affected, not what is serialized — and the models extending a fabric8 model already behaved this way. - Secondary hardening (optional, larger change): enabling a
statussubresource onSGClusterwould let the controller use.updateStatus()and confine status writes tostatus, removingspecfrom the blast radius entirely. It changes the API surface and needs a migration path, so it is worth evaluating separately.
Acceptance criteria
- An
SGClustercarrying a field that is present in the installed CRD but unknown to the running cluster-controller build survives a full cluster-controller reconciliation cycle unchanged. - Regression test covering the deserialize → mutate status → serialize round trip, asserting the unknown field is still present in the serialized body.
- Upgrade test: deploy version N, create a cluster, upgrade the operator to N+1 with a CRD field added, and verify the field is not stripped from existing
SGClusterresources before the Pods are restarted.
Related
#2939 (409s on resource updates), #3222 (unretried 409 on Pod patch), #2911 (reconciliation churn with ArgoCD), #520 (fabric8 and unrecognized CRD fields).