Skip to content

Commit a148900

Browse files
committed
✨ Add native SSA support
This change adds native server-side apply support to the client by extending it with an `Apply` method that takes an `runtime.ApplyConfiguration`.
1 parent cacd627 commit a148900

19 files changed

+558
-66
lines changed

pkg/cache/internal/informers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,7 @@ func (ip *Informers) makeListWatcher(gvk schema.GroupVersionKind, obj runtime.Ob
518518
// Structured.
519519
//
520520
default:
521-
client, err := apiutil.RESTClientForGVK(gvk, false, ip.config, ip.codecs, ip.httpClient)
521+
client, err := apiutil.RESTClientForGVK(gvk, false, false, ip.config, ip.codecs, ip.httpClient)
522522
if err != nil {
523523
return nil, err
524524
}

pkg/client/apiutil/apimachinery.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,15 +161,27 @@ func GVKForObject(obj runtime.Object, scheme *runtime.Scheme) (schema.GroupVersi
161161
// RESTClientForGVK constructs a new rest.Interface capable of accessing the resource associated
162162
// with the given GroupVersionKind. The REST client will be configured to use the negotiated serializer from
163163
// baseConfig, if set, otherwise a default serializer will be set.
164-
func RESTClientForGVK(gvk schema.GroupVersionKind, isUnstructured bool, baseConfig *rest.Config, codecs serializer.CodecFactory, httpClient *http.Client) (rest.Interface, error) {
164+
func RESTClientForGVK(
165+
gvk schema.GroupVersionKind,
166+
forceDisableProtoBuf bool,
167+
isUnstructured bool,
168+
baseConfig *rest.Config,
169+
codecs serializer.CodecFactory,
170+
httpClient *http.Client,
171+
) (rest.Interface, error) {
165172
if httpClient == nil {
166173
return nil, fmt.Errorf("httpClient must not be nil, consider using rest.HTTPClientFor(c) to create a client")
167174
}
168-
return rest.RESTClientForConfigAndClient(createRestConfig(gvk, isUnstructured, baseConfig, codecs), httpClient)
175+
return rest.RESTClientForConfigAndClient(createRestConfig(gvk, forceDisableProtoBuf, isUnstructured, baseConfig, codecs), httpClient)
169176
}
170177

171178
// createRestConfig copies the base config and updates needed fields for a new rest config.
172-
func createRestConfig(gvk schema.GroupVersionKind, isUnstructured bool, baseConfig *rest.Config, codecs serializer.CodecFactory) *rest.Config {
179+
func createRestConfig(gvk schema.GroupVersionKind,
180+
forceDisableProtoBuf bool,
181+
isUnstructured bool,
182+
baseConfig *rest.Config,
183+
codecs serializer.CodecFactory,
184+
) *rest.Config {
173185
gv := gvk.GroupVersion()
174186

175187
cfg := rest.CopyConfig(baseConfig)
@@ -183,7 +195,7 @@ func createRestConfig(gvk schema.GroupVersionKind, isUnstructured bool, baseConf
183195
cfg.UserAgent = rest.DefaultKubernetesUserAgent()
184196
}
185197
// TODO(FillZpp): In the long run, we want to check discovery or something to make sure that this is actually true.
186-
if cfg.ContentType == "" && !isUnstructured {
198+
if cfg.ContentType == "" && !forceDisableProtoBuf {
187199
protobufSchemeLock.RLock()
188200
if protobufScheme.Recognizes(gvk) {
189201
cfg.ContentType = runtime.ContentTypeProtobuf

pkg/client/applyconfigurations.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package client
18+
19+
import (
20+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
21+
"k8s.io/apimachinery/pkg/runtime"
22+
"k8s.io/apimachinery/pkg/runtime/schema"
23+
)
24+
25+
type unstructuredApplyConfiguration struct {
26+
*unstructured.Unstructured
27+
}
28+
29+
func (u *unstructuredApplyConfiguration) IsApplyConfiguration() {}
30+
31+
// ApplyConfigurationFromUnstructured creates a runtime.ApplyConfiguration from an *unstructured.Unstructured object.
32+
func ApplyConfigurationFromUnstructured(u *unstructured.Unstructured) runtime.ApplyConfiguration {
33+
return &unstructuredApplyConfiguration{Unstructured: u}
34+
}
35+
36+
type applyconfigurationRuntimeObject struct {
37+
runtime.ApplyConfiguration
38+
}
39+
40+
func (a *applyconfigurationRuntimeObject) GetObjectKind() schema.ObjectKind {
41+
return a
42+
}
43+
44+
func (a *applyconfigurationRuntimeObject) GroupVersionKind() schema.GroupVersionKind {
45+
return schema.GroupVersionKind{}
46+
}
47+
48+
func (a *applyconfigurationRuntimeObject) SetGroupVersionKind(gvk schema.GroupVersionKind) {}
49+
50+
func (a *applyconfigurationRuntimeObject) DeepCopyObject() runtime.Object {
51+
panic("applyconfigurationRuntimeObject does not support DeepCopyObject")
52+
}
53+
54+
func runtimeObjectFromApplyConfiguration(ac runtime.ApplyConfiguration) runtime.Object {
55+
return &applyconfigurationRuntimeObject{ApplyConfiguration: ac}
56+
}

pkg/client/client.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,16 @@ func (c *client) Patch(ctx context.Context, obj Object, patch Patch, opts ...Pat
329329
}
330330
}
331331

332+
func (c *client) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...ApplyOption) error {
333+
switch obj := obj.(type) {
334+
case *unstructuredApplyConfiguration:
335+
defer c.resetGroupVersionKind(obj, obj.GetObjectKind().GroupVersionKind())
336+
return c.unstructuredClient.Apply(ctx, obj, opts...)
337+
default:
338+
return c.typedClient.Apply(ctx, obj, opts...)
339+
}
340+
}
341+
332342
// Get implements client.Client.
333343
func (c *client) Get(ctx context.Context, key ObjectKey, obj Object, opts ...GetOption) error {
334344
if isUncached, err := c.shouldBypassCache(obj); err != nil {

pkg/client/client_rest_resources.go

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,17 @@ limitations under the License.
1717
package client
1818

1919
import (
20+
"fmt"
2021
"net/http"
2122
"strings"
2223
"sync"
2324

2425
"k8s.io/apimachinery/pkg/api/meta"
25-
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2626
"k8s.io/apimachinery/pkg/runtime"
2727
"k8s.io/apimachinery/pkg/runtime/schema"
2828
"k8s.io/apimachinery/pkg/runtime/serializer"
2929
"k8s.io/client-go/rest"
30+
"k8s.io/utils/ptr"
3031
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
3132
)
3233

@@ -56,13 +57,17 @@ type clientRestResources struct {
5657

5758
// newResource maps obj to a Kubernetes Resource and constructs a client for that Resource.
5859
// If the object is a list, the resource represents the item's type instead.
59-
func (c *clientRestResources) newResource(gvk schema.GroupVersionKind, isList, isUnstructured bool) (*resourceMeta, error) {
60+
func (c *clientRestResources) newResource(gvk schema.GroupVersionKind,
61+
isList bool,
62+
forceDisableProtoBuf bool,
63+
isUnstructured bool,
64+
) (*resourceMeta, error) {
6065
if strings.HasSuffix(gvk.Kind, "List") && isList {
6166
// if this was a list, treat it as a request for the item's resource
6267
gvk.Kind = gvk.Kind[:len(gvk.Kind)-4]
6368
}
6469

65-
client, err := apiutil.RESTClientForGVK(gvk, isUnstructured, c.config, c.codecs, c.httpClient)
70+
client, err := apiutil.RESTClientForGVK(gvk, forceDisableProtoBuf, isUnstructured, c.config, c.codecs, c.httpClient)
6671
if err != nil {
6772
return nil, err
6873
}
@@ -73,15 +78,44 @@ func (c *clientRestResources) newResource(gvk schema.GroupVersionKind, isList, i
7378
return &resourceMeta{Interface: client, mapping: mapping, gvk: gvk}, nil
7479
}
7580

81+
type applyConfiguration interface {
82+
GetName() *string
83+
GetNamespace() *string
84+
GetKind() *string
85+
GetAPIVersion() *string
86+
}
87+
7688
// getResource returns the resource meta information for the given type of object.
7789
// If the object is a list, the resource represents the item's type instead.
78-
func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, error) {
79-
gvk, err := apiutil.GVKForObject(obj, c.scheme)
80-
if err != nil {
81-
return nil, err
90+
func (c *clientRestResources) getResource(obj any) (*resourceMeta, error) {
91+
var gvk schema.GroupVersionKind
92+
var err error
93+
var isApplyConfiguration bool
94+
switch o := obj.(type) {
95+
case runtime.Object:
96+
gvk, err = apiutil.GVKForObject(o, c.scheme)
97+
if err != nil {
98+
return nil, err
99+
}
100+
case runtime.ApplyConfiguration:
101+
ac, ok := o.(applyConfiguration)
102+
if !ok {
103+
return nil, fmt.Errorf("%T is a runtime.ApplyConfiguration but not an applyConfiguration", o)
104+
}
105+
gv, err := schema.ParseGroupVersion(ptr.Deref(ac.GetAPIVersion(), ""))
106+
if err != nil {
107+
return nil, fmt.Errorf("failed to parse %q as GroupVersion: %w", ptr.Deref(ac.GetAPIVersion(), ""), err)
108+
}
109+
gvk.Group = gv.Group
110+
gvk.Version = gv.Version
111+
gvk.Kind = ptr.Deref(ac.GetKind(), "")
112+
isApplyConfiguration = true
113+
default:
114+
return nil, fmt.Errorf("bug: %T is neither a runtime.Object nor a runtime.ApplyConfiguration", o)
82115
}
83116

84117
_, isUnstructured := obj.(runtime.Unstructured)
118+
disableProtoBuf := isUnstructured || isApplyConfiguration
85119

86120
// It's better to do creation work twice than to not let multiple
87121
// people make requests at once
@@ -97,10 +131,15 @@ func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, er
97131
return r, nil
98132
}
99133

134+
var isList bool
135+
if runtimeObject, ok := obj.(runtime.Object); ok && meta.IsListType(runtimeObject) {
136+
isList = true
137+
}
138+
100139
// Initialize a new Client
101140
c.mu.Lock()
102141
defer c.mu.Unlock()
103-
r, err = c.newResource(gvk, meta.IsListType(obj), isUnstructured)
142+
r, err = c.newResource(gvk, isList, disableProtoBuf, isUnstructured)
104143
if err != nil {
105144
return nil, err
106145
}
@@ -109,16 +148,29 @@ func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, er
109148
}
110149

111150
// getObjMeta returns objMeta containing both type and object metadata and state.
112-
func (c *clientRestResources) getObjMeta(obj runtime.Object) (*objMeta, error) {
151+
func (c *clientRestResources) getObjMeta(obj any) (*objMeta, error) {
113152
r, err := c.getResource(obj)
114153
if err != nil {
115154
return nil, err
116155
}
117-
m, err := meta.Accessor(obj)
118-
if err != nil {
119-
return nil, err
156+
objMeta := &objMeta{resourceMeta: r}
157+
158+
switch o := obj.(type) {
159+
case runtime.Object:
160+
m, err := meta.Accessor(obj)
161+
if err != nil {
162+
return nil, err
163+
}
164+
objMeta.namespace = m.GetNamespace()
165+
objMeta.name = m.GetName()
166+
case applyConfiguration:
167+
objMeta.namespace = ptr.Deref(o.GetNamespace(), "")
168+
objMeta.name = ptr.Deref(o.GetName(), "")
169+
default:
170+
return nil, fmt.Errorf("object %T is neither a runtime.Object nor a runtime.ApplyConfiguration", obj)
120171
}
121-
return &objMeta{resourceMeta: r, Object: m}, err
172+
173+
return objMeta, nil
122174
}
123175

124176
// resourceMeta stores state for a Kubernetes type.
@@ -146,6 +198,6 @@ type objMeta struct {
146198
// resourceMeta contains type information for the object
147199
*resourceMeta
148200

149-
// Object contains meta data for the object instance
150-
metav1.Object
201+
namespace string
202+
name string
151203
}

pkg/client/client_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343
"k8s.io/apimachinery/pkg/runtime"
4444
"k8s.io/apimachinery/pkg/runtime/schema"
4545
"k8s.io/apimachinery/pkg/types"
46+
corev1applyconfigurations "k8s.io/client-go/applyconfigurations/core/v1"
4647
kscheme "k8s.io/client-go/kubernetes/scheme"
4748
"k8s.io/client-go/rest"
4849
"k8s.io/utils/ptr"
@@ -859,6 +860,97 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC
859860
})
860861
})
861862

863+
Describe("Apply", func() {
864+
Context("Unstructured Client", func() {
865+
It("should create and update a configMap using SSA", func() {
866+
cl, err := client.New(cfg, client.Options{})
867+
Expect(err).NotTo(HaveOccurred())
868+
Expect(cl).NotTo(BeNil())
869+
870+
data := map[string]any{
871+
"some-key": "some-value",
872+
}
873+
obj := &unstructured.Unstructured{Object: map[string]any{
874+
"apiVersion": "v1",
875+
"kind": "ConfigMap",
876+
"metadata": map[string]any{
877+
"name": "test-configmap",
878+
"namespace": "default",
879+
},
880+
"data": data,
881+
}}
882+
883+
err = cl.Apply(context.Background(), client.ApplyConfigurationFromUnstructured(obj), &client.ApplyOptions{FieldManager: "test-manager"})
884+
Expect(err).NotTo(HaveOccurred())
885+
886+
cm, err := clientset.CoreV1().ConfigMaps(obj.GetNamespace()).Get(context.Background(), obj.GetName(), metav1.GetOptions{})
887+
Expect(err).NotTo(HaveOccurred())
888+
889+
actualData := map[string]any{}
890+
for k, v := range cm.Data {
891+
actualData[k] = v
892+
}
893+
894+
Expect(actualData).To(BeComparableTo(data))
895+
Expect(actualData).To(BeComparableTo(obj.Object["data"]))
896+
897+
data["a-new-key"] = "a-new-value"
898+
obj.Object["data"] = data
899+
unstructured.RemoveNestedField(obj.Object, "metadata", "managedFields")
900+
901+
err = cl.Apply(context.Background(), client.ApplyConfigurationFromUnstructured(obj), &client.ApplyOptions{FieldManager: "test-manager"})
902+
Expect(err).NotTo(HaveOccurred())
903+
904+
cm, err = clientset.CoreV1().ConfigMaps(obj.GetNamespace()).Get(context.Background(), obj.GetName(), metav1.GetOptions{})
905+
Expect(err).NotTo(HaveOccurred())
906+
907+
actualData = map[string]any{}
908+
for k, v := range cm.Data {
909+
actualData[k] = v
910+
}
911+
912+
Expect(actualData).To(BeComparableTo(data))
913+
Expect(actualData).To(BeComparableTo(obj.Object["data"]))
914+
})
915+
})
916+
917+
Context("Structured Client", func() {
918+
It("should create and update a configMap using SSA", func() {
919+
cl, err := client.New(cfg, client.Options{})
920+
Expect(err).NotTo(HaveOccurred())
921+
Expect(cl).NotTo(BeNil())
922+
923+
data := map[string]string{
924+
"some-key": "some-value",
925+
}
926+
obj := corev1applyconfigurations.
927+
ConfigMap("test-configmap", "default").
928+
WithData(data)
929+
930+
err = cl.Apply(context.Background(), obj, &client.ApplyOptions{FieldManager: "test-manager"})
931+
Expect(err).NotTo(HaveOccurred())
932+
933+
cm, err := clientset.CoreV1().ConfigMaps(ptr.Deref(obj.GetNamespace(), "")).Get(context.Background(), ptr.Deref(obj.GetName(), ""), metav1.GetOptions{})
934+
Expect(err).NotTo(HaveOccurred())
935+
936+
Expect(cm.Data).To(BeComparableTo(data))
937+
Expect(cm.Data).To(BeComparableTo(obj.Data))
938+
939+
data["a-new-key"] = "a-new-value"
940+
obj.Data = data
941+
942+
err = cl.Apply(context.Background(), obj, &client.ApplyOptions{FieldManager: "test-manager"})
943+
Expect(err).NotTo(HaveOccurred())
944+
945+
cm, err = clientset.CoreV1().ConfigMaps(ptr.Deref(obj.GetNamespace(), "")).Get(context.Background(), ptr.Deref(obj.GetName(), ""), metav1.GetOptions{})
946+
Expect(err).NotTo(HaveOccurred())
947+
948+
Expect(cm.Data).To(BeComparableTo(data))
949+
Expect(cm.Data).To(BeComparableTo(obj.Data))
950+
})
951+
})
952+
})
953+
862954
Describe("SubResourceClient", func() {
863955
Context("with structured objects", func() {
864956
It("should be able to read the Scale subresource", func() {

pkg/client/dryrun.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ func (c *dryRunClient) Patch(ctx context.Context, obj Object, patch Patch, opts
8282
return c.client.Patch(ctx, obj, patch, append(opts, DryRunAll)...)
8383
}
8484

85+
func (c *dryRunClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...ApplyOption) error {
86+
return c.client.Apply(ctx, obj, append(opts, DryRunAll)...)
87+
}
88+
8589
// Get implements client.Client.
8690
func (c *dryRunClient) Get(ctx context.Context, key ObjectKey, obj Object, opts ...GetOption) error {
8791
return c.client.Get(ctx, key, obj, opts...)

0 commit comments

Comments
 (0)