Skip to content
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
62 changes: 53 additions & 9 deletions pkg/io/applicator.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ type RequestOptions struct {
// hasExplicitOwnerRefs is true if the caller explicitly sets ownerReferences
// This flag, if true, prevents the FSM reconciler from adding the default controller reference.
hasExplicitOwnerRefs bool

// ServerSideApply, if true, performs a server-side apply (PATCH with ApplyPatchType)
// instead of a JSON merge patch. FieldManager must be non-empty when this is set.
// Cannot be combined with Update.
ServerSideApply bool

// FieldManager is the manager name used for server-side apply.
// Required when ServerSideApply is true; ignored otherwise.
FieldManager string

// ForceOwnership, when ServerSideApply is true, sends ?force=true to claim ownership
// of fields previously owned by another manager (resolves SSA conflicts).
ForceOwnership bool
}

// An APIPatchingApplicator applies changes to an object by either creating or
Expand Down Expand Up @@ -77,24 +90,29 @@ func (a *APIApplicator) Apply(ctx context.Context, current client.Object, opts .
return errors.New("cannot access object metadata")
}

desired := current.DeepCopyObject().(client.Object)
// apply options to desired
if err := applyOpts(ctx, desired, requestOpts, opts); err != nil {
return fmt.Errorf("applying options: %w", err)
}

// if server-side apply is enabled, we should also use it to create the object.
// We also bypass the below get + diff loop, meaning that even if an apply doesn't change an object it will update the fieldManagers.
if requestOpts.ServerSideApply {
return a.serverSideApply(ctx, desired, requestOpts)
}

if m.GetName() == "" && m.GetGenerateName() != "" {
return a.createNewObject(ctx, current, requestOpts, opts)
}

desired := current.DeepCopyObject().(client.Object)

err := a.client.Get(ctx, types.NamespacedName{Name: m.GetName(), Namespace: m.GetNamespace()}, current)
if kerrors.IsNotFound(err) {
return a.createNewObject(ctx, current, requestOpts, opts)
} else if err != nil {
return fmt.Errorf("cannot get object: %w", err)
}

// apply options to desired
if err := applyOpts(ctx, desired, requestOpts, opts); err != nil {
return fmt.Errorf("applying options: %w", err)
}

// If there is no difference, we need not perform an update. We convert each into
// unstructured data and remove status fields before the comparison.
before, err := runtime.DefaultUnstructuredConverter.ToUnstructured(current)
Expand All @@ -112,7 +130,6 @@ func (a *APIApplicator) Apply(ctx context.Context, current client.Object, opts .
for _, managedFields := range current.GetManagedFields() {
// we're doing a client-side apply, so we assume we own all fields even if the manager is not our own.
// in other words, no need to ensure that managedFields.Manager == a.managerName
// TODO: we should explore using server-side apply
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yay

if managedFields.Subresource == "status" {
hasStatusSubresource = true
break
Expand Down Expand Up @@ -153,6 +170,34 @@ func (a *APIApplicator) Apply(ctx context.Context, current client.Object, opts .
return nil
}

// serverSideApply performs a server-side apply PATCH on the given object.
// It strips metadata fields that must not be present in an SSA body and sends
// the request with the configured FieldOwner (and optionally ForceOwnership).
func (a *APIApplicator) serverSideApply(ctx context.Context, desired client.Object, requestOpts *RequestOptions) error {
// SSA requires apiVersion and kind in the patch body.
// DeepCopy often produces objects with empty TypeMeta, so populate it explicitly.
if requestOpts.Update {
return fmt.Errorf("AsUpdate and AsServerSideApply are mutually exclusive")
}
if desired.GetObjectKind().GroupVersionKind().Empty() {
gvk, err := a.client.GroupVersionKindFor(desired)
if err != nil {
return fmt.Errorf("getting GVK for server-side apply: %w", err)
}
desired.GetObjectKind().SetGroupVersionKind(gvk)
}

patchOpts := []client.PatchOption{client.FieldOwner(requestOpts.FieldManager)}
if requestOpts.ForceOwnership {
patchOpts = append(patchOpts, client.ForceOwnership)
}

if err := a.client.Patch(ctx, desired, client.Apply, patchOpts...); err != nil {
return fmt.Errorf("cannot server-side apply object: %w", err)
}
return nil
}

// createNewObject handles creating a new object with options applied
func (a *APIApplicator) createNewObject(ctx context.Context, obj client.Object, requestOpts *RequestOptions, opts []ApplyOption) error {
// apply options to obj
Expand Down Expand Up @@ -250,7 +295,6 @@ func (a *APIApplicator) ApplyStatus(ctx context.Context, o client.Object, opts .

type patch struct{ from runtime.Object }

// TODO switch to server side apply
func (p *patch) Type() types.PatchType { return types.MergePatchType }
func (p *patch) Data(_ client.Object) ([]byte, error) { return json.Marshal(p.from) }

Expand Down
104 changes: 103 additions & 1 deletion pkg/io/applicator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,109 @@ var _ = Describe("Applicator", func() {
TestField: "test-update-will-fail",
}

Expect(errors.IsNotFound(applicator.ApplyStatus(ctx, testResourceNoSubresourcePatch.DeepCopy())))
Expect(errors.IsNotFound(applicator.ApplyStatus(ctx, testResourceNoSubresourcePatch.DeepCopy()))).To(BeTrue())
})
})

It("should support server-side apply", func() {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "svc-ssa",
Namespace: "default",
},
Spec: corev1.ServiceSpec{
Ports: []corev1.ServicePort{
{
Name: "http",
Protocol: corev1.ProtocolTCP,
Port: 8080,
TargetPort: intstr.IntOrString{IntVal: 8080},
},
},
Selector: map[string]string{"app": "test"},
ExternalIPs: []string{"1.1.1.1"},
},
}

By("creating an object that does not exist via SSA", func() {
Expect(applicator.Apply(ctx, svc.DeepCopy(), io.AsServerSideApply("test-manager"))).To(Succeed())

Eventually(func(g Gomega) {
actual := &corev1.Service{}
g.Expect(c.Get(ctx, client.ObjectKeyFromObject(svc), actual)).To(Succeed())
g.Expect(actual.Spec.ExternalIPs).To(Equal([]string{"1.1.1.1"}))
// confirm the SSA manager entry is present in managedFields
var found bool
for _, mf := range actual.GetManagedFields() {
if mf.Manager == "test-manager" && string(mf.Operation) == "Apply" {
found = true
break
}
}
g.Expect(found).To(BeTrue(), "expected managedFields entry for test-manager with Apply operation")
}).Should(Succeed())
})

By("applying an existing object via SSA updates the field and keeps the managed fields entry", func() {
svcUpdated := svc.DeepCopy()
svcUpdated.Spec.ExternalIPs = []string{"2.2.2.2"}
Expect(applicator.Apply(ctx, svcUpdated, io.AsServerSideApply("test-manager"))).To(Succeed())

Eventually(func(g Gomega) {
actual := &corev1.Service{}
g.Expect(c.Get(ctx, client.ObjectKeyFromObject(svc), actual)).To(Succeed())
g.Expect(actual.Spec.ExternalIPs).To(Equal([]string{"2.2.2.2"}))
var found bool
for _, mf := range actual.GetManagedFields() {
if mf.Manager == "test-manager" && string(mf.Operation) == "Apply" {
found = true
break
}
}
g.Expect(found).To(BeTrue(), "expected managedFields entry for test-manager with Apply operation")
}).Should(Succeed())
})

By("conflicting SSA apply without force returns a conflict error", func() {
// manager-a already owns spec.externalIPs from the steps above.
// manager-b tries to claim the same field without force.
svcB := svc.DeepCopy()
svcB.Spec.ExternalIPs = []string{"3.3.3.3"}
err := applicator.Apply(ctx, svcB, io.AsServerSideApply("manager-b"))
Expect(err).To(HaveOccurred())
Expect(errors.IsConflict(err)).To(BeTrue(), "expected a conflict error but got: %v", err)
})

By("conflicting SSA apply with WithForceOwnership succeeds and transfers ownership", func() {
svcB := svc.DeepCopy()
svcB.Spec.ExternalIPs = []string{"3.3.3.3"}
Expect(applicator.Apply(ctx, svcB, io.AsServerSideApply("manager-b"), io.WithForceOwnership())).To(Succeed())

Eventually(func(g Gomega) {
actual := &corev1.Service{}
g.Expect(c.Get(ctx, client.ObjectKeyFromObject(svc), actual)).To(Succeed())
g.Expect(actual.Spec.ExternalIPs).To(Equal([]string{"3.3.3.3"}))
// manager-b should now own the field
var found bool
for _, mf := range actual.GetManagedFields() {
if mf.Manager == "manager-b" && string(mf.Operation) == "Apply" {
found = true
break
}
}
g.Expect(found).To(BeTrue(), "expected managedFields entry for manager-b with Apply operation")
}).Should(Succeed())
})

By("combining AsServerSideApply with AsUpdate returns a mutual exclusion error", func() {
err := applicator.Apply(ctx, svc.DeepCopy(), io.AsServerSideApply("test-manager"), io.AsUpdate())
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("mutually exclusive"))
})

By("AsServerSideApply with an empty fieldManager returns an error", func() {
err := applicator.Apply(ctx, svc.DeepCopy(), io.AsServerSideApply(""))
Expect(err).To(HaveOccurred())
})
})

Expand Down
41 changes: 41 additions & 0 deletions pkg/io/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io

import (
"context"
"fmt"

"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand Down Expand Up @@ -71,3 +72,43 @@ func AsUpdate() ApplyOption {
return nil
}
}

// AsServerSideApply causes Apply (and ApplyStatus) to use server-side apply (PATCH with ApplyPatchType)
// instead of the default JSON merge patch. fieldManager is the name recorded in managedFields to track
// which fields this caller owns. Use this when multiple controllers manage different fields of the same
// resource — the apiserver will enforce that two managers cannot own the same field without an explicit
// conflict resolution.
//
// Cannot be combined with AsUpdate. May be combined with WithForceOwnership to claim fields
// currently owned by another manager.
//
// Important caveats:
// - Server-side apply skips the local Get + diff loop; idempotency is handled by the apiserver.
// - Fields with the `omitempty` JSON tag whose Go value is the zero value (e.g. 0, "", false) will be
// omitted from the JSON body and will NOT be set to zero — they will instead release ownership.
// Use pointer types (*int, *string, etc.) for fields you need to explicitly zero via SSA.
func AsServerSideApply(fieldManager string) ApplyOption {
return func(ctx context.Context, _ client.Object, requestOpts *RequestOptions) error {
if fieldManager == "" {
return fmt.Errorf("AsServerSideApply requires a non-empty fieldManager name")
}
requestOpts.ServerSideApply = true
requestOpts.FieldManager = fieldManager
return nil
}
}

// WithForceOwnership, when used with AsServerSideApply, sends ?force=true to claim ownership of fields
// currently owned by another field manager, resolving any SSA conflicts. Without this option, the
// apiserver returns HTTP 409 if another manager owns a field present in the apply body.
//
// This is typically needed during a one-time migration from client-side apply/patch to server-side apply,
// to reclaim ownership of fields that were previously written by a different manager name.
//
// No-op when AsServerSideApply is not also set.
func WithForceOwnership() ApplyOption {
return func(ctx context.Context, _ client.Object, requestOpts *RequestOptions) error {
requestOpts.ForceOwnership = true
return nil
}
}