Skip to content

Commit b3d57df

Browse files
committed
Move server-side apply function to a shared package
Multiple controllers had a method for this, but the implementations differed slightly. This combines their fixes and tests them in a single place.
1 parent 0e8e7e3 commit b3d57df

File tree

18 files changed

+98
-172
lines changed

18 files changed

+98
-172
lines changed

internal/bridge/crunchybridgecluster/apply.go

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

internal/bridge/crunchybridgecluster/postgres.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"sigs.k8s.io/controller-runtime/pkg/client"
1616

1717
"github.com/crunchydata/postgres-operator/internal/bridge"
18+
"github.com/crunchydata/postgres-operator/internal/controller/runtime"
1819
"github.com/crunchydata/postgres-operator/internal/naming"
1920
"github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
2021
)
@@ -152,7 +153,7 @@ func (r *CrunchyBridgeClusterReconciler) reconcilePostgresRoleSecrets(
152153
roleSecrets[roleName], err = r.generatePostgresRoleSecret(cluster, role, clusterRole)
153154
}
154155
if err == nil {
155-
err = errors.WithStack(r.apply(ctx, roleSecrets[roleName]))
156+
err = errors.WithStack(runtime.Apply(ctx, r.Writer, roleSecrets[roleName]))
156157
}
157158
if err != nil {
158159
log.Error(err, "Issue creating role secret.")

internal/controller/pgupgrade/apply.go

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

internal/controller/pgupgrade/pgupgrade_controller.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ func (r *PGUpgradeReconciler) Reconcile(ctx context.Context, upgrade *v1beta1.PG
437437

438438
// TODO: error from apply could mean that the job exists with a different spec.
439439
if err == nil && !upgradeJobComplete {
440-
err = errors.WithStack(r.apply(ctx,
440+
err = errors.WithStack(runtime.Apply(ctx, r.Writer,
441441
r.generateUpgradeJob(ctx, upgrade, world.ClusterPrimary, config.FetchKeyCommand(&world.Cluster.Spec))))
442442
}
443443

@@ -448,7 +448,7 @@ func (r *PGUpgradeReconciler) Reconcile(ctx context.Context, upgrade *v1beta1.PG
448448
if err == nil && upgradeJobComplete && !removeDataJobsComplete {
449449
for _, sts := range world.ClusterReplicas {
450450
if err == nil {
451-
err = r.apply(ctx, r.generateRemoveDataJob(ctx, upgrade, sts))
451+
err = runtime.Apply(ctx, r.Writer, r.generateRemoveDataJob(ctx, upgrade, sts))
452452
}
453453
}
454454
}

internal/controller/postgrescluster/apply.go

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,10 @@ package postgrescluster
66

77
import (
88
"context"
9-
"reflect"
109

11-
corev1 "k8s.io/api/core/v1"
12-
"k8s.io/apimachinery/pkg/api/equality"
1310
"sigs.k8s.io/controller-runtime/pkg/client"
1411

15-
"github.com/crunchydata/postgres-operator/internal/kubeapi"
12+
"github.com/crunchydata/postgres-operator/internal/controller/runtime"
1613
)
1714

1815
// apply sends an apply patch to object's endpoint in the Kubernetes API and
@@ -21,41 +18,5 @@ import (
2118
// - https://docs.k8s.io/reference/using-api/server-side-apply/#managers
2219
// - https://docs.k8s.io/reference/using-api/server-side-apply/#conflicts
2320
func (r *Reconciler) apply(ctx context.Context, object client.Object) error {
24-
// Generate an apply-patch by comparing the object to its zero value.
25-
zero := reflect.New(reflect.TypeOf(object).Elem()).Interface()
26-
data, err := client.MergeFrom(zero.(client.Object)).Data(object)
27-
apply := client.RawPatch(client.Apply.Type(), data)
28-
29-
// Keep a copy of the object before any API calls.
30-
intent := object.DeepCopyObject()
31-
patch := kubeapi.NewJSONPatch()
32-
33-
// Send the apply-patch with force=true.
34-
if err == nil {
35-
err = r.Writer.Patch(ctx, object, apply, client.ForceOwnership)
36-
}
37-
38-
// Some fields cannot be server-side applied correctly. When their outcome
39-
// does not match the intent, send a json-patch to get really specific.
40-
switch actual := object.(type) {
41-
case *corev1.Service:
42-
applyServiceSpec(patch, actual.Spec, intent.(*corev1.Service).Spec, "spec")
43-
}
44-
45-
// Send the json-patch when necessary.
46-
if err == nil && !patch.IsEmpty() {
47-
err = r.Writer.Patch(ctx, object, patch)
48-
}
49-
return err
50-
}
51-
52-
// applyServiceSpec is called by Reconciler.apply to work around issues
53-
// with server-side apply.
54-
func applyServiceSpec(
55-
patch *kubeapi.JSON6902, actual, intent corev1.ServiceSpec, path ...string,
56-
) {
57-
// Service.Spec.Selector cannot be unset; perhaps https://issue.k8s.io/117447
58-
if !equality.Semantic.DeepEqual(actual.Selector, intent.Selector) {
59-
patch.Replace(append(path, "selector")...)(intent.Selector)
60-
}
21+
return runtime.Apply(ctx, r.Writer, object)
6122
}

internal/controller/postgrescluster/controller_ref_manager.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
"sigs.k8s.io/controller-runtime/pkg/handler"
1717
"sigs.k8s.io/controller-runtime/pkg/reconcile"
1818

19-
"github.com/crunchydata/postgres-operator/internal/kubeapi"
19+
"github.com/crunchydata/postgres-operator/internal/controller/runtime"
2020
"github.com/crunchydata/postgres-operator/internal/logging"
2121
"github.com/crunchydata/postgres-operator/internal/naming"
2222
"github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
@@ -31,7 +31,7 @@ func (r *Reconciler) adoptObject(ctx context.Context, postgresCluster *v1beta1.P
3131
return err
3232
}
3333

34-
patchBytes, err := kubeapi.NewMergePatch().
34+
patchBytes, err := runtime.NewMergePatch().
3535
Add("metadata", "ownerReferences")(obj.GetOwnerReferences()).Bytes()
3636
if err != nil {
3737
return err
@@ -160,8 +160,8 @@ func (r *Reconciler) manageControllerRefs(ctx context.Context,
160160
func (r *Reconciler) releaseObject(ctx context.Context,
161161
postgresCluster *v1beta1.PostgresCluster, obj client.Object) error {
162162

163-
// TODO create a strategic merge type in kubeapi instead of using Merge7386
164-
patch, err := kubeapi.NewMergePatch().
163+
// TODO create a strategic merge type instead of using Merge7386
164+
patch, err := runtime.NewMergePatch().
165165
Add("metadata", "ownerReferences")([]map[string]string{{
166166
"$patch": "delete",
167167
"uid": string(postgresCluster.GetUID()),
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright 2021 - 2025 Crunchy Data Solutions, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package runtime
6+
7+
import (
8+
"context"
9+
10+
corev1 "k8s.io/api/core/v1"
11+
"k8s.io/apimachinery/pkg/api/equality"
12+
"sigs.k8s.io/controller-runtime/pkg/client"
13+
)
14+
15+
// Apply sends an apply patch with force=true using cc and updates object with any returned content.
16+
// The client is responsible for setting fieldManager; see [client.WithFieldOwner].
17+
//
18+
// - https://docs.k8s.io/reference/using-api/server-side-apply#managers
19+
// - https://docs.k8s.io/reference/using-api/server-side-apply#conflicts
20+
func Apply[
21+
// NOTE: This interface can go away following https://go.dev/issue/47487.
22+
ClientPatch interface {
23+
Patch(context.Context, client.Object, client.Patch, ...client.PatchOption) error
24+
},
25+
T interface{ client.Object },
26+
](ctx context.Context, cc ClientPatch, object T) error {
27+
// Generate an apply-patch by comparing the object to its zero value.
28+
data, err := client.MergeFrom(*new(T)).Data(object)
29+
apply := client.RawPatch(client.Apply.Type(), data)
30+
31+
// Keep a copy of the object before any API calls.
32+
intent := object.DeepCopyObject()
33+
34+
// Send the apply-patch with force=true.
35+
if err == nil {
36+
err = cc.Patch(ctx, object, apply, client.ForceOwnership)
37+
}
38+
39+
// Some fields cannot be server-side applied correctly.
40+
// When their outcome does not match the intent, send a json-patch to get really specific.
41+
patch := NewJSONPatch()
42+
43+
switch actual := any(object).(type) {
44+
case *corev1.Service:
45+
intent := intent.(*corev1.Service)
46+
47+
// Service.Spec.Selector cannot be unset; perhaps https://issue.k8s.io/117447
48+
if !equality.Semantic.DeepEqual(actual.Spec.Selector, intent.Spec.Selector) {
49+
patch.Replace("spec", "selector")(intent.Spec.Selector)
50+
}
51+
}
52+
53+
// Send the json-patch when necessary.
54+
if err == nil && !patch.IsEmpty() {
55+
err = cc.Patch(ctx, object, patch)
56+
}
57+
return err
58+
}

internal/controller/postgrescluster/apply_test.go renamed to internal/controller/runtime/apply_test.go

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

5-
package postgrescluster
5+
package runtime_test
66

77
import (
8-
"context"
98
"errors"
109
"regexp"
1110
"strings"
@@ -23,17 +22,18 @@ import (
2322
"sigs.k8s.io/controller-runtime/pkg/client"
2423
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
2524

25+
"github.com/crunchydata/postgres-operator/internal/controller/runtime"
2626
"github.com/crunchydata/postgres-operator/internal/testing/require"
2727
)
2828

2929
func TestServerSideApply(t *testing.T) {
30-
ctx := context.Background()
31-
cfg, cc := setupKubernetes(t)
30+
ctx := t.Context()
31+
config, base := require.Kubernetes2(t)
3232
require.ParallelCapacity(t, 0)
3333

34-
ns := setupNamespace(t, cc)
34+
ns := require.Namespace(t, base)
3535

36-
dc, err := discovery.NewDiscoveryClientForConfig(cfg)
36+
dc, err := discovery.NewDiscoveryClientForConfig(config)
3737
assert.NilError(t, err)
3838

3939
server, err := dc.ServerVersion()
@@ -43,8 +43,7 @@ func TestServerSideApply(t *testing.T) {
4343
assert.NilError(t, err)
4444

4545
t.Run("ObjectMeta", func(t *testing.T) {
46-
cc := client.WithFieldOwner(cc, t.Name())
47-
reconciler := Reconciler{Writer: cc}
46+
cc := client.WithFieldOwner(base, t.Name())
4847
constructor := func() *corev1.ConfigMap {
4948
var cm corev1.ConfigMap
5049
cm.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap"))
@@ -78,17 +77,16 @@ func TestServerSideApply(t *testing.T) {
7877
assert.Assert(t, after.GetResourceVersion() == before.GetResourceVersion())
7978
}
8079

81-
// Our apply method generates the correct apply-patch.
80+
// Our [runtime.Apply] generates the correct apply-patch.
8281
again := constructor()
83-
assert.NilError(t, reconciler.apply(ctx, again))
82+
assert.NilError(t, runtime.Apply(ctx, cc, again))
8483
assert.Assert(t, again.GetResourceVersion() != "")
8584
assert.Assert(t, again.GetResourceVersion() == after.GetResourceVersion(),
8685
"expected to correctly no-op")
8786
})
8887

8988
t.Run("ControllerReference", func(t *testing.T) {
90-
cc := client.WithFieldOwner(cc, t.Name())
91-
reconciler := Reconciler{Writer: cc}
89+
cc := client.WithFieldOwner(base, t.Name())
9290

9391
// Setup two possible controllers.
9492
controller1 := new(corev1.ConfigMap)
@@ -128,8 +126,8 @@ func TestServerSideApply(t *testing.T) {
128126
assert.Assert(t, len(status.ErrStatus.Details.Causes) != 0)
129127
assert.Equal(t, status.ErrStatus.Details.Causes[0].Field, "metadata.ownerReferences")
130128

131-
// Try to change the controller using our apply method.
132-
err2 := reconciler.apply(ctx, applied)
129+
// Try to change the controller using our [runtime.Apply].
130+
err2 := runtime.Apply(ctx, cc, applied)
133131

134132
// Same result; patch not accepted.
135133
assert.DeepEqual(t, err1, err2,
@@ -162,8 +160,7 @@ func TestServerSideApply(t *testing.T) {
162160
{"empty", make(map[string]string)},
163161
} {
164162
t.Run(tt.name, func(t *testing.T) {
165-
cc := client.WithFieldOwner(cc, t.Name())
166-
reconciler := Reconciler{Writer: cc}
163+
cc := client.WithFieldOwner(base, t.Name())
167164

168165
intent := constructor(tt.name + "-selector")
169166
intent.Spec.Selector = tt.selector
@@ -190,9 +187,9 @@ func TestServerSideApply(t *testing.T) {
190187
assert.Assert(t, len(after.Spec.Selector) != len(intent.Spec.Selector),
191188
"got %v", after.Spec.Selector)
192189

193-
// Our apply method corrects it.
190+
// Our [runtime.Apply] corrects it.
194191
again := intent.DeepCopy()
195-
assert.NilError(t, reconciler.apply(ctx, again))
192+
assert.NilError(t, runtime.Apply(ctx, cc, again))
196193
assert.Assert(t,
197194
equality.Semantic.DeepEqual(again.Spec.Selector, intent.Spec.Selector),
198195
"\n--- again.Spec.Selector\n+++ intent.Spec.Selector\n%v",

internal/controller/runtime/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ func (fn ClientUpdate) Update(ctx context.Context, obj client.Object, opts ...cl
7676
return fn(ctx, obj, opts...)
7777
}
7878

79+
// WarningHandler implements [rest.WarningHandler] and [rest.WarningHandlerWithContext] as a single function.
7980
type WarningHandler func(ctx context.Context, code int, agent string, text string)
8081

8182
func (fn WarningHandler) HandleWarningHeader(code int, agent string, text string) {

internal/controller/runtime/conversion.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func FromUnstructuredObject[
5050
FromUnstructured(object.UnstructuredContent(), result)
5151
}
5252

53-
// ToUnstructuredList returns a copy of list by marshaling through JSON.
53+
// ToUnstructuredList returns a copy of list using reflection.
5454
func ToUnstructuredList(list client.ObjectList) (*unstructured.UnstructuredList, error) {
5555
content, err := runtime.
5656
DefaultUnstructuredConverter.
@@ -61,7 +61,7 @@ func ToUnstructuredList(list client.ObjectList) (*unstructured.UnstructuredList,
6161
return result, err
6262
}
6363

64-
// ToUnstructuredObject returns a copy of object by marshaling through JSON.
64+
// ToUnstructuredObject returns a copy of object using reflection.
6565
func ToUnstructuredObject(object client.Object) (*unstructured.Unstructured, error) {
6666
content, err := runtime.
6767
DefaultUnstructuredConverter.

0 commit comments

Comments
 (0)