Skip to content

Commit 948d554

Browse files
authored
⚠️ Add native SSA support (#3253)
* ✨ 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`. * Field validation * Revert "Field validation" This reverts commit ad1966a. This doesn't work, the server will always error on additional fields when using SSA, even when fieldValiation=None is configured. * Explain why we don't update the namespaced client * Client: Force ownership supports Apply * Fieldvalidation wrapper: Do nothing for `Apply` * Improve explanation as to why unstructured shouldn't originate from an api type * Test Apply in interceptor * Smaller fixes * Implement Apply support in the namespaced client * Apply comes first * Add tests for apply options
1 parent e08f24a commit 948d554

22 files changed

+829
-75
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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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+
"fmt"
21+
22+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
23+
"k8s.io/apimachinery/pkg/runtime"
24+
"k8s.io/apimachinery/pkg/runtime/schema"
25+
"k8s.io/utils/ptr"
26+
)
27+
28+
type unstructuredApplyConfiguration struct {
29+
*unstructured.Unstructured
30+
}
31+
32+
func (u *unstructuredApplyConfiguration) IsApplyConfiguration() {}
33+
34+
// ApplyConfigurationFromUnstructured creates a runtime.ApplyConfiguration from an *unstructured.Unstructured object.
35+
//
36+
// Do not use Unstructured objects here that were generated from API objects, as its impossible to tell
37+
// if a zero value was explicitly set.
38+
func ApplyConfigurationFromUnstructured(u *unstructured.Unstructured) runtime.ApplyConfiguration {
39+
return &unstructuredApplyConfiguration{Unstructured: u}
40+
}
41+
42+
type applyconfigurationRuntimeObject struct {
43+
runtime.ApplyConfiguration
44+
}
45+
46+
func (a *applyconfigurationRuntimeObject) GetObjectKind() schema.ObjectKind {
47+
return a
48+
}
49+
50+
func (a *applyconfigurationRuntimeObject) GroupVersionKind() schema.GroupVersionKind {
51+
return schema.GroupVersionKind{}
52+
}
53+
54+
func (a *applyconfigurationRuntimeObject) SetGroupVersionKind(gvk schema.GroupVersionKind) {}
55+
56+
func (a *applyconfigurationRuntimeObject) DeepCopyObject() runtime.Object {
57+
panic("applyconfigurationRuntimeObject does not support DeepCopyObject")
58+
}
59+
60+
func runtimeObjectFromApplyConfiguration(ac runtime.ApplyConfiguration) runtime.Object {
61+
return &applyconfigurationRuntimeObject{ApplyConfiguration: ac}
62+
}
63+
64+
func gvkFromApplyConfiguration(ac applyConfiguration) (schema.GroupVersionKind, error) {
65+
var gvk schema.GroupVersionKind
66+
gv, err := schema.ParseGroupVersion(ptr.Deref(ac.GetAPIVersion(), ""))
67+
if err != nil {
68+
return gvk, fmt.Errorf("failed to parse %q as GroupVersion: %w", ptr.Deref(ac.GetAPIVersion(), ""), err)
69+
}
70+
gvk.Group = gv.Group
71+
gvk.Version = gv.Version
72+
gvk.Kind = ptr.Deref(ac.GetKind(), "")
73+
74+
return gvk, nil
75+
}

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: 64 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,41 @@ 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+
gvk, err = gvkFromApplyConfiguration(ac)
106+
if err != nil {
107+
return nil, err
108+
}
109+
isApplyConfiguration = true
110+
default:
111+
return nil, fmt.Errorf("bug: %T is neither a runtime.Object nor a runtime.ApplyConfiguration", o)
82112
}
83113

84114
_, isUnstructured := obj.(runtime.Unstructured)
115+
forceDisableProtoBuf := isUnstructured || isApplyConfiguration
85116

86117
// It's better to do creation work twice than to not let multiple
87118
// people make requests at once
@@ -97,10 +128,15 @@ func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, er
97128
return r, nil
98129
}
99130

131+
var isList bool
132+
if runtimeObject, ok := obj.(runtime.Object); ok && meta.IsListType(runtimeObject) {
133+
isList = true
134+
}
135+
100136
// Initialize a new Client
101137
c.mu.Lock()
102138
defer c.mu.Unlock()
103-
r, err = c.newResource(gvk, meta.IsListType(obj), isUnstructured)
139+
r, err = c.newResource(gvk, isList, forceDisableProtoBuf, isUnstructured)
104140
if err != nil {
105141
return nil, err
106142
}
@@ -109,16 +145,29 @@ func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, er
109145
}
110146

111147
// getObjMeta returns objMeta containing both type and object metadata and state.
112-
func (c *clientRestResources) getObjMeta(obj runtime.Object) (*objMeta, error) {
148+
func (c *clientRestResources) getObjMeta(obj any) (*objMeta, error) {
113149
r, err := c.getResource(obj)
114150
if err != nil {
115151
return nil, err
116152
}
117-
m, err := meta.Accessor(obj)
118-
if err != nil {
119-
return nil, err
153+
objMeta := &objMeta{resourceMeta: r}
154+
155+
switch o := obj.(type) {
156+
case runtime.Object:
157+
m, err := meta.Accessor(obj)
158+
if err != nil {
159+
return nil, err
160+
}
161+
objMeta.namespace = m.GetNamespace()
162+
objMeta.name = m.GetName()
163+
case applyConfiguration:
164+
objMeta.namespace = ptr.Deref(o.GetNamespace(), "")
165+
objMeta.name = ptr.Deref(o.GetName(), "")
166+
default:
167+
return nil, fmt.Errorf("object %T is neither a runtime.Object nor a runtime.ApplyConfiguration", obj)
120168
}
121-
return &objMeta{resourceMeta: r, Object: m}, err
169+
170+
return objMeta, nil
122171
}
123172

124173
// resourceMeta stores state for a Kubernetes type.
@@ -146,6 +195,6 @@ type objMeta struct {
146195
// resourceMeta contains type information for the object
147196
*resourceMeta
148197

149-
// Object contains meta data for the object instance
150-
metav1.Object
198+
namespace string
199+
name string
151200
}

pkg/client/client_test.go

Lines changed: 96 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,101 @@ 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 = map[string]any{
898+
"a-new-key": "a-new-value",
899+
}
900+
obj.Object["data"] = data
901+
unstructured.RemoveNestedField(obj.Object, "metadata", "managedFields")
902+
903+
err = cl.Apply(context.Background(), client.ApplyConfigurationFromUnstructured(obj), &client.ApplyOptions{FieldManager: "test-manager"})
904+
Expect(err).NotTo(HaveOccurred())
905+
906+
cm, err = clientset.CoreV1().ConfigMaps(obj.GetNamespace()).Get(context.Background(), obj.GetName(), metav1.GetOptions{})
907+
Expect(err).NotTo(HaveOccurred())
908+
909+
actualData = map[string]any{}
910+
for k, v := range cm.Data {
911+
actualData[k] = v
912+
}
913+
914+
Expect(actualData).To(BeComparableTo(data))
915+
Expect(actualData).To(BeComparableTo(obj.Object["data"]))
916+
})
917+
})
918+
919+
Context("Structured Client", func() {
920+
It("should create and update a configMap using SSA", func() {
921+
cl, err := client.New(cfg, client.Options{})
922+
Expect(err).NotTo(HaveOccurred())
923+
Expect(cl).NotTo(BeNil())
924+
925+
data := map[string]string{
926+
"some-key": "some-value",
927+
}
928+
obj := corev1applyconfigurations.
929+
ConfigMap("test-configmap", "default").
930+
WithData(data)
931+
932+
err = cl.Apply(context.Background(), obj, &client.ApplyOptions{FieldManager: "test-manager"})
933+
Expect(err).NotTo(HaveOccurred())
934+
935+
cm, err := clientset.CoreV1().ConfigMaps(ptr.Deref(obj.GetNamespace(), "")).Get(context.Background(), ptr.Deref(obj.GetName(), ""), metav1.GetOptions{})
936+
Expect(err).NotTo(HaveOccurred())
937+
938+
Expect(cm.Data).To(BeComparableTo(data))
939+
Expect(cm.Data).To(BeComparableTo(obj.Data))
940+
941+
data = map[string]string{
942+
"a-new-key": "a-new-value",
943+
}
944+
obj.Data = data
945+
946+
err = cl.Apply(context.Background(), obj, &client.ApplyOptions{FieldManager: "test-manager"})
947+
Expect(err).NotTo(HaveOccurred())
948+
949+
cm, err = clientset.CoreV1().ConfigMaps(ptr.Deref(obj.GetNamespace(), "")).Get(context.Background(), ptr.Deref(obj.GetName(), ""), metav1.GetOptions{})
950+
Expect(err).NotTo(HaveOccurred())
951+
952+
Expect(cm.Data).To(BeComparableTo(data))
953+
Expect(cm.Data).To(BeComparableTo(obj.Data))
954+
})
955+
})
956+
})
957+
862958
Describe("SubResourceClient", func() {
863959
Context("with structured objects", func() {
864960
It("should be able to read the Scale subresource", func() {

0 commit comments

Comments
 (0)