diff --git a/pkg/io/applicator.go b/pkg/io/applicator.go index f9a2753..4bee547 100644 --- a/pkg/io/applicator.go +++ b/pkg/io/applicator.go @@ -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 @@ -77,12 +90,22 @@ 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) @@ -90,11 +113,6 @@ func (a *APIApplicator) Apply(ctx context.Context, current client.Object, opts . 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) @@ -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 if managedFields.Subresource == "status" { hasStatusSubresource = true break @@ -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 @@ -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) } diff --git a/pkg/io/applicator_test.go b/pkg/io/applicator_test.go index f8645d2..8fd4c9e 100644 --- a/pkg/io/applicator_test.go +++ b/pkg/io/applicator_test.go @@ -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()) }) }) diff --git a/pkg/io/options.go b/pkg/io/options.go index 704d37d..8c7cf92 100644 --- a/pkg/io/options.go +++ b/pkg/io/options.go @@ -2,6 +2,7 @@ package io import ( "context" + "fmt" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -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 + } +}