Skip to content

⚠️ Add native SSA support #3253

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/cache/internal/informers.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ func (ip *Informers) makeListWatcher(gvk schema.GroupVersionKind, obj runtime.Ob
// Structured.
//
default:
client, err := apiutil.RESTClientForGVK(gvk, false, ip.config, ip.codecs, ip.httpClient)
client, err := apiutil.RESTClientForGVK(gvk, false, false, ip.config, ip.codecs, ip.httpClient)
if err != nil {
return nil, err
}
Expand Down
20 changes: 16 additions & 4 deletions pkg/client/apiutil/apimachinery.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,27 @@ func GVKForObject(obj runtime.Object, scheme *runtime.Scheme) (schema.GroupVersi
// RESTClientForGVK constructs a new rest.Interface capable of accessing the resource associated
// with the given GroupVersionKind. The REST client will be configured to use the negotiated serializer from
// baseConfig, if set, otherwise a default serializer will be set.
func RESTClientForGVK(gvk schema.GroupVersionKind, isUnstructured bool, baseConfig *rest.Config, codecs serializer.CodecFactory, httpClient *http.Client) (rest.Interface, error) {
func RESTClientForGVK(
gvk schema.GroupVersionKind,
forceDisableProtoBuf bool,
isUnstructured bool,
baseConfig *rest.Config,
codecs serializer.CodecFactory,
httpClient *http.Client,
) (rest.Interface, error) {
if httpClient == nil {
return nil, fmt.Errorf("httpClient must not be nil, consider using rest.HTTPClientFor(c) to create a client")
}
return rest.RESTClientForConfigAndClient(createRestConfig(gvk, isUnstructured, baseConfig, codecs), httpClient)
return rest.RESTClientForConfigAndClient(createRestConfig(gvk, forceDisableProtoBuf, isUnstructured, baseConfig, codecs), httpClient)
}

// createRestConfig copies the base config and updates needed fields for a new rest config.
func createRestConfig(gvk schema.GroupVersionKind, isUnstructured bool, baseConfig *rest.Config, codecs serializer.CodecFactory) *rest.Config {
func createRestConfig(gvk schema.GroupVersionKind,
forceDisableProtoBuf bool,
isUnstructured bool,
baseConfig *rest.Config,
codecs serializer.CodecFactory,
) *rest.Config {
gv := gvk.GroupVersion()

cfg := rest.CopyConfig(baseConfig)
Expand All @@ -183,7 +195,7 @@ func createRestConfig(gvk schema.GroupVersionKind, isUnstructured bool, baseConf
cfg.UserAgent = rest.DefaultKubernetesUserAgent()
}
// TODO(FillZpp): In the long run, we want to check discovery or something to make sure that this is actually true.
if cfg.ContentType == "" && !isUnstructured {
if cfg.ContentType == "" && !forceDisableProtoBuf {
protobufSchemeLock.RLock()
if protobufScheme.Recognizes(gvk) {
cfg.ContentType = runtime.ContentTypeProtobuf
Expand Down
56 changes: 56 additions & 0 deletions pkg/client/applyconfigurations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
Copyright 2025 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package client

import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)

type unstructuredApplyConfiguration struct {
*unstructured.Unstructured
}

func (u *unstructuredApplyConfiguration) IsApplyConfiguration() {}

// ApplyConfigurationFromUnstructured creates a runtime.ApplyConfiguration from an *unstructured.Unstructured object.
func ApplyConfigurationFromUnstructured(u *unstructured.Unstructured) runtime.ApplyConfiguration {
return &unstructuredApplyConfiguration{Unstructured: u}
}

type applyconfigurationRuntimeObject struct {
runtime.ApplyConfiguration
}

func (a *applyconfigurationRuntimeObject) GetObjectKind() schema.ObjectKind {
return a
}

func (a *applyconfigurationRuntimeObject) GroupVersionKind() schema.GroupVersionKind {
return schema.GroupVersionKind{}
}

func (a *applyconfigurationRuntimeObject) SetGroupVersionKind(gvk schema.GroupVersionKind) {}

func (a *applyconfigurationRuntimeObject) DeepCopyObject() runtime.Object {
panic("applyconfigurationRuntimeObject does not support DeepCopyObject")
}

func runtimeObjectFromApplyConfiguration(ac runtime.ApplyConfiguration) runtime.Object {
return &applyconfigurationRuntimeObject{ApplyConfiguration: ac}
}
10 changes: 10 additions & 0 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,16 @@ func (c *client) Patch(ctx context.Context, obj Object, patch Patch, opts ...Pat
}
}

func (c *client) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...ApplyOption) error {
switch obj := obj.(type) {
case *unstructuredApplyConfiguration:
defer c.resetGroupVersionKind(obj, obj.GetObjectKind().GroupVersionKind())
return c.unstructuredClient.Apply(ctx, obj, opts...)
default:
return c.typedClient.Apply(ctx, obj, opts...)
}
}

// Get implements client.Client.
func (c *client) Get(ctx context.Context, key ObjectKey, obj Object, opts ...GetOption) error {
if isUncached, err := c.shouldBypassCache(obj); err != nil {
Expand Down
82 changes: 67 additions & 15 deletions pkg/client/client_rest_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@ limitations under the License.
package client

import (
"fmt"
"net/http"
"strings"
"sync"

"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
)

Expand Down Expand Up @@ -56,13 +57,17 @@ type clientRestResources struct {

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

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

type applyConfiguration interface {
GetName() *string
GetNamespace() *string
GetKind() *string
GetAPIVersion() *string
}

// getResource returns the resource meta information for the given type of object.
// If the object is a list, the resource represents the item's type instead.
func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, error) {
gvk, err := apiutil.GVKForObject(obj, c.scheme)
if err != nil {
return nil, err
func (c *clientRestResources) getResource(obj any) (*resourceMeta, error) {
var gvk schema.GroupVersionKind
var err error
var isApplyConfiguration bool
switch o := obj.(type) {
case runtime.Object:
gvk, err = apiutil.GVKForObject(o, c.scheme)
if err != nil {
return nil, err
}
case runtime.ApplyConfiguration:
ac, ok := o.(applyConfiguration)
if !ok {
return nil, fmt.Errorf("%T is a runtime.ApplyConfiguration but not an applyConfiguration", o)
}
gv, err := schema.ParseGroupVersion(ptr.Deref(ac.GetAPIVersion(), ""))
if err != nil {
return nil, fmt.Errorf("failed to parse %q as GroupVersion: %w", ptr.Deref(ac.GetAPIVersion(), ""), err)
}
gvk.Group = gv.Group
gvk.Version = gv.Version
gvk.Kind = ptr.Deref(ac.GetKind(), "")
isApplyConfiguration = true
default:
return nil, fmt.Errorf("bug: %T is neither a runtime.Object nor a runtime.ApplyConfiguration", o)
}

_, isUnstructured := obj.(runtime.Unstructured)
disableProtoBuf := isUnstructured || isApplyConfiguration

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

var isList bool
if runtimeObject, ok := obj.(runtime.Object); ok && meta.IsListType(runtimeObject) {
isList = true
}

// Initialize a new Client
c.mu.Lock()
defer c.mu.Unlock()
r, err = c.newResource(gvk, meta.IsListType(obj), isUnstructured)
r, err = c.newResource(gvk, isList, disableProtoBuf, isUnstructured)
if err != nil {
return nil, err
}
Expand All @@ -109,16 +148,29 @@ func (c *clientRestResources) getResource(obj runtime.Object) (*resourceMeta, er
}

// getObjMeta returns objMeta containing both type and object metadata and state.
func (c *clientRestResources) getObjMeta(obj runtime.Object) (*objMeta, error) {
func (c *clientRestResources) getObjMeta(obj any) (*objMeta, error) {
r, err := c.getResource(obj)
if err != nil {
return nil, err
}
m, err := meta.Accessor(obj)
if err != nil {
return nil, err
objMeta := &objMeta{resourceMeta: r}

switch o := obj.(type) {
case runtime.Object:
m, err := meta.Accessor(obj)
if err != nil {
return nil, err
}
objMeta.namespace = m.GetNamespace()
objMeta.name = m.GetName()
case applyConfiguration:
objMeta.namespace = ptr.Deref(o.GetNamespace(), "")
objMeta.name = ptr.Deref(o.GetName(), "")
default:
return nil, fmt.Errorf("object %T is neither a runtime.Object nor a runtime.ApplyConfiguration", obj)
}
return &objMeta{resourceMeta: r, Object: m}, err

return objMeta, nil
}

// resourceMeta stores state for a Kubernetes type.
Expand Down Expand Up @@ -146,6 +198,6 @@ type objMeta struct {
// resourceMeta contains type information for the object
*resourceMeta

// Object contains meta data for the object instance
metav1.Object
namespace string
name string
}
92 changes: 92 additions & 0 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
corev1applyconfigurations "k8s.io/client-go/applyconfigurations/core/v1"
kscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/utils/ptr"
Expand Down Expand Up @@ -859,6 +860,97 @@ U5wwSivyi7vmegHKmblOzNVKA5qPO8zWzqBC
})
})

Describe("Apply", func() {
Context("Unstructured Client", func() {
It("should create and update a configMap using SSA", func() {
cl, err := client.New(cfg, client.Options{})
Expect(err).NotTo(HaveOccurred())
Expect(cl).NotTo(BeNil())

data := map[string]any{
"some-key": "some-value",
}
obj := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]any{
"name": "test-configmap",
"namespace": "default",
},
"data": data,
}}

err = cl.Apply(context.Background(), client.ApplyConfigurationFromUnstructured(obj), &client.ApplyOptions{FieldManager: "test-manager"})
Expect(err).NotTo(HaveOccurred())

cm, err := clientset.CoreV1().ConfigMaps(obj.GetNamespace()).Get(context.Background(), obj.GetName(), metav1.GetOptions{})
Expect(err).NotTo(HaveOccurred())

actualData := map[string]any{}
for k, v := range cm.Data {
actualData[k] = v
}

Expect(actualData).To(BeComparableTo(data))
Expect(actualData).To(BeComparableTo(obj.Object["data"]))

data["a-new-key"] = "a-new-value"
obj.Object["data"] = data
unstructured.RemoveNestedField(obj.Object, "metadata", "managedFields")

err = cl.Apply(context.Background(), client.ApplyConfigurationFromUnstructured(obj), &client.ApplyOptions{FieldManager: "test-manager"})
Expect(err).NotTo(HaveOccurred())

cm, err = clientset.CoreV1().ConfigMaps(obj.GetNamespace()).Get(context.Background(), obj.GetName(), metav1.GetOptions{})
Expect(err).NotTo(HaveOccurred())

actualData = map[string]any{}
for k, v := range cm.Data {
actualData[k] = v
}

Expect(actualData).To(BeComparableTo(data))
Expect(actualData).To(BeComparableTo(obj.Object["data"]))
})
})

Context("Structured Client", func() {
It("should create and update a configMap using SSA", func() {
cl, err := client.New(cfg, client.Options{})
Expect(err).NotTo(HaveOccurred())
Expect(cl).NotTo(BeNil())

data := map[string]string{
"some-key": "some-value",
}
obj := corev1applyconfigurations.
ConfigMap("test-configmap", "default").
WithData(data)

err = cl.Apply(context.Background(), obj, &client.ApplyOptions{FieldManager: "test-manager"})
Expect(err).NotTo(HaveOccurred())

cm, err := clientset.CoreV1().ConfigMaps(ptr.Deref(obj.GetNamespace(), "")).Get(context.Background(), ptr.Deref(obj.GetName(), ""), metav1.GetOptions{})
Expect(err).NotTo(HaveOccurred())

Expect(cm.Data).To(BeComparableTo(data))
Expect(cm.Data).To(BeComparableTo(obj.Data))

data["a-new-key"] = "a-new-value"
obj.Data = data

err = cl.Apply(context.Background(), obj, &client.ApplyOptions{FieldManager: "test-manager"})
Expect(err).NotTo(HaveOccurred())

cm, err = clientset.CoreV1().ConfigMaps(ptr.Deref(obj.GetNamespace(), "")).Get(context.Background(), ptr.Deref(obj.GetName(), ""), metav1.GetOptions{})
Expect(err).NotTo(HaveOccurred())

Expect(cm.Data).To(BeComparableTo(data))
Expect(cm.Data).To(BeComparableTo(obj.Data))
})
})
})

Describe("SubResourceClient", func() {
Context("with structured objects", func() {
It("should be able to read the Scale subresource", func() {
Expand Down
4 changes: 4 additions & 0 deletions pkg/client/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ func (c *dryRunClient) Patch(ctx context.Context, obj Object, patch Patch, opts
return c.client.Patch(ctx, obj, patch, append(opts, DryRunAll)...)
}

func (c *dryRunClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...ApplyOption) error {
return c.client.Apply(ctx, obj, append(opts, DryRunAll)...)
}

// Get implements client.Client.
func (c *dryRunClient) Get(ctx context.Context, key ObjectKey, obj Object, opts ...GetOption) error {
return c.client.Get(ctx, key, obj, opts...)
Expand Down
Loading