Skip to content

Commit 037f556

Browse files
armrumnencia
andcommitted
fix(services): use JSON merge patch for managed service spec reconciliation (cloudnative-pg#10311)
Previously, the patch strategy reconciler compared service specs by starting from the proposed spec and explicitly preserving a hard-coded list of Kubernetes-managed fields. Any field missing from that list — such as LoadBalancerClass set by a cloud provider — would be zeroed out, causing API errors on immutable fields and silent spec corruption on mutable ones. Replace this deny-list approach by computing an RFC 7386 JSON Merge Patch between the last-applied and proposed specs, then applying it onto the living service. A last-applied spec annotation (cnpg.io/lastAppliedSpec) tracks what was previously applied so that intentional field removals produce null entries in the patch. This naturally preserves any field the operator doesn't control — including fields set by cloud providers, admission webhooks, or future Kubernetes versions — without needing to enumerate them. Closes cloudnative-pg#10132 Signed-off-by: Armando Ruocco <armando.ruocco@enterprisedb.com> Signed-off-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com> Co-authored-by: Marco Nenciarini <marco.nenciarini@enterprisedb.com> (cherry picked from commit e20d4d5)
1 parent 901b435 commit 037f556

File tree

8 files changed

+841
-511
lines changed

8 files changed

+841
-511
lines changed

internal/controller/cluster_create.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ func (r *ClusterReconciler) serviceReconciler(
401401
return nil
402402
}
403403
contextLogger.Info("creating service")
404+
servicespec.SetLastApplied(&proposed.ObjectMeta, &proposed.Spec)
404405
return r.Create(ctx, proposed)
405406
}
406407
if err != nil {
@@ -441,9 +442,12 @@ func (r *ClusterReconciler) serviceReconciler(
441442
shouldUpdate = true
442443
}
443444

444-
// we check if the service spec has changed, preserving Kubernetes-managed fields
445-
patchedSpec := proposed.Spec.DeepCopy()
446-
servicespec.PreserveKubernetesDefaults(patchedSpec, &livingService.Spec)
445+
// Three-way merge: start from living spec, overlay proposed changes,
446+
// and use last-applied annotation to detect intentional field removals
447+
patchedSpec := livingService.Spec.DeepCopy()
448+
if err := servicespec.ApplyProposedChanges(patchedSpec, &proposed.Spec, livingService.Annotations); err != nil {
449+
return err
450+
}
447451
if !reflect.DeepEqual(*patchedSpec, livingService.Spec) {
448452
livingService.Spec = *patchedSpec
449453
shouldUpdate = true
@@ -454,6 +458,8 @@ func (r *ClusterReconciler) serviceReconciler(
454458
}
455459

456460
if strategy == apiv1.ServiceUpdateStrategyPatch {
461+
// Store the proposed spec for future three-way merges
462+
servicespec.SetLastApplied(&livingService.ObjectMeta, &proposed.Spec)
457463
contextLogger.Info("reconciling service")
458464
return r.Update(ctx, &livingService)
459465
}

internal/controller/cluster_create_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,16 @@ var _ = Describe("cluster_create unit tests", func() {
197197
}, &afterChangesService)
198198
Expect(err).ToNot(HaveOccurred())
199199

200-
Expect(afterChangesService.Spec.Selector).ToNot(Equal(before.Spec.Selector))
201-
Expect(afterChangesService.Spec.Selector).To(Equal(expectedLabels))
200+
for k, v := range expectedLabels {
201+
Expect(afterChangesService.Spec.Selector).To(HaveKeyWithValue(k, v))
202+
}
202203
Expect(afterChangesService.Labels).To(Equal(before.Labels))
203-
Expect(afterChangesService.Annotations).To(Equal(before.Annotations))
204+
// The reconciler now stores a lastAppliedServiceSpec annotation
205+
// for three-way merge, so we check that original annotations are preserved
206+
for k, v := range before.Annotations {
207+
Expect(afterChangesService.Annotations).To(HaveKeyWithValue(k, v))
208+
}
209+
Expect(afterChangesService.Annotations).To(HaveKey(utils.LastAppliedSpecAnnotationName))
204210
}
205211

206212
var readOnlyService, readWriteService, readService, anyService *corev1.Service

internal/controller/pooler_update.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ func (r *PoolerReconciler) reconcileService(
140140

141141
if resources.Service == nil {
142142
contextLog.Info("Creating the service")
143+
servicespec.SetLastApplied(&expectedService.ObjectMeta, &expectedService.Spec)
143144
err := r.Create(ctx, expectedService)
144145
if err != nil && !apierrs.IsAlreadyExists(err) {
145146
return err
@@ -149,15 +150,23 @@ func (r *PoolerReconciler) reconcileService(
149150
}
150151

151152
patchedService := resources.Service.DeepCopy()
152-
patchedService.Spec = expectedService.Spec
153-
servicespec.PreserveKubernetesDefaults(&patchedService.Spec, &resources.Service.Spec)
153+
if err := servicespec.ApplyProposedChanges(
154+
&patchedService.Spec,
155+
&expectedService.Spec,
156+
resources.Service.Annotations,
157+
); err != nil {
158+
return err
159+
}
154160
utils.MergeObjectsMetadata(patchedService, expectedService)
155161

156162
if reflect.DeepEqual(patchedService.ObjectMeta, resources.Service.ObjectMeta) &&
157163
reflect.DeepEqual(patchedService.Spec, resources.Service.Spec) {
158164
return nil
159165
}
160166

167+
// Store the proposed spec for future three-way merges
168+
servicespec.SetLastApplied(&patchedService.ObjectMeta, &expectedService.Spec)
169+
161170
contextLog.Info("Updating the service metadata")
162171

163172
return r.Patch(ctx, patchedService, client.MergeFrom(resources.Service))

pkg/servicespec/defaults.go

Lines changed: 0 additions & 104 deletions
This file was deleted.

0 commit comments

Comments
 (0)