diff --git a/integration_tests/apply_replace.lua b/integration_tests/apply_replace.lua new file mode 100644 index 000000000..cd34bd6c5 --- /dev/null +++ b/integration_tests/apply_replace.lua @@ -0,0 +1,137 @@ +local user = User.new() +local team = Team.new("apply-replace-team", "Apply field removal testing", "#apply-replace-team") +team:addMember(user) + +Test.rest("create resources with fields that will be omitted", function(t) + t.addHeader("x-user-email", user:email()) + t.send("POST", "/api/v1/teams/apply-replace-team/environments/dev/apply", [[ + {"resources": [ + { + "apiVersion": "nais.io/v1alpha1", + "kind": "Application", + "metadata": { + "name": "my-app", + "labels": {"kept": "same", "removed": "old"}, + "annotations": {"removed": "old"} + }, + "spec": { + "image": "example.com/my-app:v1", + "resources": { + "limits": {"cpu": "1", "memory": "512Mi"}, + "requests": {"cpu": "100m", "memory": "256Mi"} + } + } + }, + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "my-config"}, + "data": {"kept": "old", "removed": "old"}, + "binaryData": {"removed": "b2xk"} + } + ]} + ]]) + t.check(200, { + results = { + { resource = "Application/my-app", environmentName = "dev", status = "created" }, + { resource = "ConfigMap/my-config", environmentName = "dev", status = "created" }, + }, + }) +end) + +local replacement = [[ + {"resources": [ + { + "apiVersion": "nais.io/v1alpha1", + "kind": "Application", + "metadata": { + "name": "my-app", + "labels": {"kept": "same"} + }, + "spec": { + "image": "example.com/my-app:v1", + "resources": { + "limits": {"memory": "512Mi"}, + "requests": {"cpu": "100m", "memory": "256Mi"} + } + } + }, + { + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": {"name": "my-config"}, + "data": {"kept": "new"} + } + ]} +]] + +Test.rest("apply replaces omitted fields and reports changes", function(t) + t.addHeader("x-user-email", user:email()) + t.send("POST", "/api/v1/teams/apply-replace-team/environments/dev/apply", replacement) + t.check(200, { + results = { + { + resource = "Application/my-app", + environmentName = "dev", + status = "applied", + changedFields = { + { field = "metadata.annotations.removed", oldValue = "old" }, + { field = "metadata.labels.removed", oldValue = "old" }, + { field = "spec.resources.limits.cpu", oldValue = "1" }, + }, + }, + { + resource = "ConfigMap/my-config", + environmentName = "dev", + status = "applied", + changedFields = { + { field = "binaryData.removed", oldValue = "b2xk" }, + { field = "data.kept", oldValue = "old", newValue = "new" }, + { field = "data.removed", oldValue = "old" }, + }, + }, + }, + }) +end) + +Test.k8s("omitted CPU limit and metadata are removed from the stored Application", function(t) + t.check("nais.io/v1alpha1", "applications", "dev", team:slug(), "my-app", { + apiVersion = "nais.io/v1alpha1", + kind = "Application", + metadata = { + name = "my-app", + namespace = team:slug(), + labels = { kept = "same" }, + }, + spec = { + image = "example.com/my-app:v1", + resources = { + limits = { memory = "512Mi" }, + requests = { cpu = "100m", memory = "256Mi" }, + }, + }, + }) +end) + +Test.k8s("omitted data keys and top-level fields are removed from the stored ConfigMap", function(t) + t.check("v1", "configmaps", "dev", team:slug(), "my-config", { + apiVersion = "v1", + kind = "ConfigMap", + metadata = { + name = "my-config", + namespace = team:slug(), + }, + data = { kept = "new" }, + }) +end) + +Test.rest("reapplying the replacement has no changes", function(t) + t.addHeader("x-user-email", user:email()) + t.send("POST", "/api/v1/teams/apply-replace-team/environments/dev/apply", replacement) + t.check(200, { + results = { + { resource = "Application/my-app", environmentName = "dev", status = "applied" }, + { resource = "ConfigMap/my-config", environmentName = "dev", status = "applied" }, + }, + }) +end) diff --git a/internal/apply/client.go b/internal/apply/client.go index 13add645b..a8fd9b943 100644 --- a/internal/apply/client.go +++ b/internal/apply/client.go @@ -2,20 +2,18 @@ package apply import ( "context" - "encoding/json" "fmt" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" ) const fieldManager = "nais-api" -// ApplyResult holds the before and after states of a server-side apply operation. +// ApplyResult holds the before and after states of an apply operation. type ApplyResult struct { // Before is the state of the object before the apply. Nil if the object did not exist. Before *unstructured.Unstructured @@ -25,9 +23,8 @@ type ApplyResult struct { Created bool } -// ApplyResource performs a Kubernetes server-side apply for a single resource. -// It fetches the current state (before), applies the resource, and returns both -// before and after states so the caller can diff them. +// ApplyResource creates missing resources and replaces existing resources. +// It returns both before and after states so the caller can diff them. func ApplyResource( ctx context.Context, client dynamic.Interface, @@ -46,7 +43,6 @@ func ApplyResource( resourceClient := client.Resource(gvr).Namespace(namespace) - // Step 1: Get the current state of the object (before-state). before, err := resourceClient.Get(ctx, name, metav1.GetOptions{}) if err != nil { if !apierrors.IsNotFound(err) { @@ -55,26 +51,34 @@ func ApplyResource( before = nil } - // Step 2: Marshal the object to JSON for the apply patch. - data, err := json.Marshal(obj.Object) - if err != nil { - return nil, fmt.Errorf("marshaling resource to JSON: %w", err) + submittedVersion := obj.GetResourceVersion() + if submittedVersion != "" { + if before == nil { + return nil, apierrors.NewConflict(gvr.GroupResource(), name, fmt.Errorf("resourceVersion %q refers to a resource that no longer exists", submittedVersion)) + } + if submittedVersion != before.GetResourceVersion() { + return nil, apierrors.NewConflict(gvr.GroupResource(), name, fmt.Errorf("requested resourceVersion %q does not match current version %q", submittedVersion, before.GetResourceVersion())) + } } - - // Step 3: Server-side apply using PATCH with ApplyPatchType. - after, err := resourceClient.Patch( - ctx, - name, - types.ApplyPatchType, - data, - metav1.PatchOptions{ + desired := obj.DeepCopy() + var after *unstructured.Unstructured + if before != nil { + desired.SetResourceVersion(before.GetResourceVersion()) + after, err = resourceClient.Update(ctx, desired, metav1.UpdateOptions{ FieldManager: fieldManager, - Force: new(true), FieldValidation: metav1.FieldValidationStrict, - }, - ) - if err != nil { - return nil, fmt.Errorf("applying %s/%s: %w", namespace, name, err) + }) + if err != nil { + return nil, fmt.Errorf("updating %s/%s: %w", namespace, name, err) + } + } else { + after, err = resourceClient.Create(ctx, desired, metav1.CreateOptions{ + FieldManager: fieldManager, + FieldValidation: metav1.FieldValidationStrict, + }) + if err != nil { + return nil, fmt.Errorf("creating %s/%s: %w", namespace, name, err) + } } return &ApplyResult{ diff --git a/internal/apply/client_test.go b/internal/apply/client_test.go new file mode 100644 index 000000000..1cc43de26 --- /dev/null +++ b/internal/apply/client_test.go @@ -0,0 +1,325 @@ +package apply + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/managedfields" + dynfake "k8s.io/client-go/dynamic/fake" + k8stesting "k8s.io/client-go/testing" +) + +func TestApplyResource_Create(t *testing.T) { + gvr := schema.GroupVersionResource{Group: "nais.io", Version: "v1alpha1", Resource: "applications"} + client := dynfake.NewSimpleDynamicClient(runtime.NewScheme()) + desired := applyTestApplication() + input := desired.DeepCopy() + + result, err := ApplyResource(t.Context(), client, gvr, desired) + if err != nil { + t.Fatal(err) + } + if !result.Created || result.Before != nil { + t.Fatal("expected creation without a before-state") + } + if diff := cmp.Diff(input.Object, result.After.Object); diff != "" { + t.Errorf("created object mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(input.Object, desired.Object); diff != "" { + t.Errorf("input was mutated (-want +got):\n%s", diff) + } + actions := client.Actions() + if len(actions) != 2 || actions[0].GetVerb() != "get" || actions[1].GetVerb() != "create" { + t.Fatalf("expected GET and CREATE, got %v", actions) + } + options := actions[1].(k8stesting.CreateActionImpl).GetCreateOptions() + if options.FieldManager != fieldManager || options.FieldValidation != metav1.FieldValidationStrict { + t.Errorf("unexpected create options: %+v", options) + } + stored, err := client.Resource(gvr).Namespace(desired.GetNamespace()).Get(t.Context(), desired.GetName(), metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(result.After.Object, stored.Object); diff != "" { + t.Errorf("stored object mismatch (-want +got):\n%s", diff) + } +} + +func TestApplyResource_ReplacesRegardlessOfOwnership(t *testing.T) { + for _, resource := range []struct { + kind string + gvr schema.GroupVersionResource + beforeFields map[string]any + desiredFields map[string]any + }{ + { + kind: "Application", + gvr: schema.GroupVersionResource{Group: "nais.io", Version: "v1alpha1", Resource: "applications"}, + beforeFields: map[string]any{ + "spec": map[string]any{"image": "example.com/my-app:v1", "resources": map[string]any{"limits": map[string]any{"cpu": "1"}}}, + }, + desiredFields: map[string]any{"spec": map[string]any{"image": "example.com/my-app:v2"}}, + }, + { + kind: "ConfigMap", + gvr: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, + beforeFields: map[string]any{ + "data": map[string]any{"kept": "old", "removed": "old"}, + "binaryData": map[string]any{"removed": "b2xk"}, + }, + desiredFields: map[string]any{"data": map[string]any{"kept": "new"}}, + }, + { + kind: "Secret", + gvr: schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, + beforeFields: map[string]any{"data": map[string]any{"kept": "b2xk", "removed": "b2xk"}}, + desiredFields: map[string]any{"data": map[string]any{"kept": "bmV3"}}, + }, + } { + for _, owner := range []struct { + manager string + apply bool + }{ + {manager: fieldManager, apply: true}, + {manager: "kubectl-create"}, + {manager: "kubectl-client-side-apply"}, + {manager: "deployd"}, + {manager: "resource-controller", apply: true}, + {manager: "kubectl", apply: true}, + } { + t.Run(resource.kind+"/"+owner.manager, func(t *testing.T) { + gvk := resource.gvr.GroupVersion().WithKind(resource.kind) + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(gvk, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(gvk.GroupVersion().WithKind(gvk.Kind+"List"), &unstructured.UnstructuredList{}) + tracker := k8stesting.NewFieldManagedObjectTracker(scheme, serializer.NewCodecFactory(scheme).UniversalDecoder(), managedfields.NewDeducedTypeConverter()) + client := dynfake.NewSimpleDynamicClient(scheme) + client.PrependReactor("*", "*", k8stesting.ObjectReaction(tracker)) + resourceClient := client.Resource(resource.gvr).Namespace("my-team") + existing := (&unstructured.Unstructured{Object: resource.beforeFields}).DeepCopy() + existing.SetGroupVersionKind(gvk) + existing.SetName("my-resource") + existing.SetNamespace("my-team") + existing.SetResourceVersion("42") + existing.SetLabels(map[string]string{"kept": "same", "removed": "old"}) + existing.SetAnnotations(map[string]string{"removed": "old"}) + existing.SetFinalizers([]string{"controller.example.com/cleanup"}) + existing.SetOwnerReferences([]metav1.OwnerReference{{ + APIVersion: "nais.io/v1alpha1", Kind: "Application", Name: "owner", UID: "owner-uid", + }}) + var before *unstructured.Unstructured + var err error + if owner.apply { + data, marshalErr := json.Marshal(existing.Object) + if marshalErr != nil { + t.Fatal(marshalErr) + } + before, err = resourceClient.Patch(t.Context(), existing.GetName(), types.ApplyPatchType, data, metav1.PatchOptions{FieldManager: owner.manager}) + } else { + before, err = resourceClient.Create(t.Context(), existing, metav1.CreateOptions{FieldManager: owner.manager}) + } + if err != nil { + t.Fatal(err) + } + desired := (&unstructured.Unstructured{Object: resource.desiredFields}).DeepCopy() + desired.SetGroupVersionKind(gvk) + desired.SetName(existing.GetName()) + desired.SetNamespace(existing.GetNamespace()) + desired.SetLabels(map[string]string{"kept": "same", "added": "new"}) + input := desired.DeepCopy() + client.ClearActions() + + result, err := ApplyResource(t.Context(), client, resource.gvr, desired) + if err != nil { + t.Fatal(err) + } + if result.Created || result.Before == nil { + t.Fatal("expected an existing resource") + } + if diff := cmp.Diff(before.Object, result.Before.Object); diff != "" { + t.Errorf("before-state mismatch (-want +got):\n%s", diff) + } + actual := result.After.DeepCopy() + actual.SetManagedFields(nil) + actual.SetResourceVersion("") + if diff := cmp.Diff(input.Object, actual.Object); diff != "" { + t.Errorf("replacement mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(input.Object, desired.Object); diff != "" { + t.Errorf("input was mutated (-want +got):\n%s", diff) + } + actions := client.Actions() + if len(actions) != 2 || actions[0].GetVerb() != "get" || actions[1].GetVerb() != "update" { + t.Fatalf("expected GET and UPDATE, got %v", actions) + } + update := actions[1].(k8stesting.UpdateActionImpl) + options := update.GetUpdateOptions() + if options.FieldManager != fieldManager || options.FieldValidation != metav1.FieldValidationStrict { + t.Errorf("unexpected update options: %+v", options) + } + if version := update.GetObject().(*unstructured.Unstructured).GetResourceVersion(); version != "42" { + t.Errorf("update resourceVersion = %q, want 42", version) + } + stored, err := resourceClient.Get(t.Context(), desired.GetName(), metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(result.After.Object, stored.Object); diff != "" { + t.Errorf("stored object mismatch (-want +got):\n%s", diff) + } + reapplied, err := ApplyResource(t.Context(), client, resource.gvr, desired) + if err != nil { + t.Fatal(err) + } + if reapplied.Created || len(Diff(reapplied.Before, reapplied.After)) != 0 { + t.Error("expected reapplication without configuration changes") + } + }) + } + } +} + +func TestApplyResource_ResourceVersion(t *testing.T) { + gvr := schema.GroupVersionResource{Group: "nais.io", Version: "v1alpha1", Resource: "applications"} + for _, test := range []struct { + name string + submittedVersion string + missing bool + concurrentUpdate bool + wantConflict bool + }{ + {name: "uses fetched version when omitted"}, + {name: "accepts matching version", submittedVersion: "42"}, + {name: "rejects stale version", submittedVersion: "41", wantConflict: true}, + {name: "does not recreate deleted versioned object", submittedVersion: "42", missing: true, wantConflict: true}, + {name: "creates unversioned object", missing: true}, + {name: "rejects concurrent update without submitted version", concurrentUpdate: true, wantConflict: true}, + {name: "rejects concurrent update with submitted version", submittedVersion: "42", concurrentUpdate: true, wantConflict: true}, + } { + t.Run(test.name, func(t *testing.T) { + client := dynfake.NewSimpleDynamicClient(runtime.NewScheme()) + desired := applyTestApplication() + desired.SetResourceVersion(test.submittedVersion) + input := desired.DeepCopy() + before := applyTestApplication() + before.SetResourceVersion("42") + if !test.missing { + client.PrependReactor("get", "*", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, before.DeepCopy(), nil + }) + } + client.PrependReactor("update", "*", func(action k8stesting.Action) (bool, runtime.Object, error) { + submitted := action.(k8stesting.UpdateAction).GetObject().(*unstructured.Unstructured) + if version := submitted.GetResourceVersion(); version != "42" { + t.Errorf("update resourceVersion = %q, want 42", version) + } + if test.concurrentUpdate { + return true, nil, apierrors.NewConflict(gvr.GroupResource(), desired.GetName(), errors.New("resourceVersion changed")) + } + after := submitted.DeepCopy() + after.SetResourceVersion("43") + return true, after, nil + }) + + result, err := ApplyResource(t.Context(), client, gvr, desired) + if test.wantConflict { + if !apierrors.IsConflict(err) || result != nil { + t.Fatalf("expected concurrency conflict, got result=%+v, err=%v", result, err) + } + } else { + if err != nil { + t.Fatal(err) + } + if result.Created != test.missing { + t.Errorf("created = %t, want %t", result.Created, test.missing) + } + if !test.missing { + if diff := cmp.Diff(before.Object, result.Before.Object); diff != "" { + t.Errorf("before-state mismatch (-want +got):\n%s", diff) + } + } + } + wantActions := 2 + if test.wantConflict && !test.concurrentUpdate { + wantActions = 1 + } + if actions := client.Actions(); len(actions) != wantActions { + t.Errorf("expected %d actions, got %v", wantActions, actions) + } + if diff := cmp.Diff(input.Object, desired.Object); diff != "" { + t.Errorf("input was mutated (-want +got):\n%s", diff) + } + }) + } +} + +func TestApplyResource_KubernetesErrors(t *testing.T) { + gvr := schema.GroupVersionResource{Group: "nais.io", Version: "v1alpha1", Resource: "applications"} + for _, test := range []struct { + name string + verb string + existing bool + err error + }{ + {name: "get forbidden", verb: "get", err: apierrors.NewForbidden(gvr.GroupResource(), "my-app", errors.New("denied"))}, + {name: "create forbidden", verb: "create", err: apierrors.NewForbidden(gvr.GroupResource(), "my-app", errors.New("denied"))}, + {name: "creation rejected", verb: "create", err: apierrors.NewBadRequest("invalid resource")}, + {name: "created concurrently", verb: "create", err: apierrors.NewAlreadyExists(gvr.GroupResource(), "my-app")}, + {name: "update forbidden", verb: "update", existing: true, err: apierrors.NewForbidden(gvr.GroupResource(), "my-app", errors.New("denied"))}, + {name: "update rejected", verb: "update", existing: true, err: apierrors.NewBadRequest("immutable field changed")}, + {name: "deleted concurrently", verb: "update", existing: true, err: apierrors.NewNotFound(gvr.GroupResource(), "my-app")}, + } { + t.Run(test.name, func(t *testing.T) { + client := dynfake.NewSimpleDynamicClient(runtime.NewScheme()) + desired := applyTestApplication() + if test.existing { + before := desired.DeepCopy() + before.SetResourceVersion("42") + client.PrependReactor("get", "*", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, before.DeepCopy(), nil + }) + } + client.PrependReactor(test.verb, "*", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, test.err + }) + result, err := ApplyResource(t.Context(), client, gvr, desired) + if !errors.Is(err, test.err) { + t.Fatalf("error = %v, want wrapped %v", err, test.err) + } + if result != nil { + t.Errorf("expected no result on failure, got %+v", result) + } + wantActions := 2 + if test.verb == "get" { + wantActions = 1 + } + if actions := client.Actions(); len(actions) != wantActions || actions[len(actions)-1].GetVerb() != test.verb { + t.Errorf("unexpected actions after failure: %v", actions) + } + }) + } +} + +func applyTestApplication() *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "nais.io/v1alpha1", + "kind": "Application", + "metadata": map[string]any{"name": "my-app", "namespace": "my-team"}, + "spec": map[string]any{ + "image": "example.com/my-app:v1", + "resources": map[string]any{ + "limits": map[string]any{"cpu": "1", "memory": "512Mi"}, + "requests": map[string]any{"cpu": "100m", "memory": "256Mi"}, + }, + }, + }} +} diff --git a/internal/kubernetes/fake/apply_test.go b/internal/kubernetes/fake/apply_test.go new file mode 100644 index 000000000..4d7649913 --- /dev/null +++ b/internal/kubernetes/fake/apply_test.go @@ -0,0 +1,92 @@ +package fake_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/nais/api/internal/apply" + "github.com/nais/api/internal/kubernetes" + "github.com/nais/api/internal/kubernetes/fake" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestApplyReplacesResourcesInFakeClient(t *testing.T) { + for _, test := range []struct { + kind string + gvr schema.GroupVersionResource + existing map[string]any + desired map[string]any + }{ + { + kind: "Application", + gvr: schema.GroupVersionResource{Group: "nais.io", Version: "v1alpha1", Resource: "applications"}, + existing: map[string]any{"spec": map[string]any{ + "image": "example.com/app:v1", "resources": map[string]any{"limits": map[string]any{"cpu": "1", "memory": "512Mi"}}, + }}, + desired: map[string]any{"spec": map[string]any{ + "image": "example.com/app:v1", "resources": map[string]any{"limits": map[string]any{"memory": "512Mi"}}, + }}, + }, + { + kind: "ConfigMap", + gvr: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, + existing: map[string]any{"data": map[string]any{"kept": "old", "removed": "old"}, "binaryData": map[string]any{"removed": "b2xk"}}, + desired: map[string]any{"data": map[string]any{"kept": "new"}}, + }, + { + kind: "Secret", + gvr: schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, + existing: map[string]any{"data": map[string]any{"kept": "b2xk", "removed": "b2xk"}}, + desired: map[string]any{"data": map[string]any{"kept": "bmV3"}}, + }, + } { + t.Run(test.kind, func(t *testing.T) { + scheme, err := kubernetes.NewScheme() + if err != nil { + t.Fatal(err) + } + client := fake.NewDynamicClient(scheme) + existing := (&unstructured.Unstructured{Object: test.existing}).DeepCopy() + existing.SetGroupVersionKind(test.gvr.GroupVersion().WithKind(test.kind)) + existing.SetName("my-resource") + existing.SetNamespace("my-team") + existing.SetLabels(map[string]string{"kept": "same", "removed": "old"}) + existing.SetAnnotations(map[string]string{"removed": "old"}) + existing.SetFinalizers([]string{"controller.example.com/cleanup"}) + existing.SetResourceVersion("42") + existing.SetManagedFields([]metav1.ManagedFieldsEntry{{ + Manager: "resource-controller", Operation: metav1.ManagedFieldsOperationApply, + APIVersion: existing.GetAPIVersion(), FieldsType: "FieldsV1", + FieldsV1: &metav1.FieldsV1{Raw: []byte(`{"f:metadata":{"f:finalizers":{}}}`)}, + }}) + if _, err := client.Resource(test.gvr).Namespace(existing.GetNamespace()).Create(t.Context(), existing, metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + desired := (&unstructured.Unstructured{Object: test.desired}).DeepCopy() + desired.SetGroupVersionKind(existing.GroupVersionKind()) + desired.SetName(existing.GetName()) + desired.SetNamespace(existing.GetNamespace()) + desired.SetLabels(map[string]string{"kept": "same"}) + + result, err := apply.ApplyResource(t.Context(), client, test.gvr, desired) + if err != nil { + t.Fatal(err) + } + actual := result.After.DeepCopy() + actual.SetManagedFields(nil) + actual.SetResourceVersion("") + if diff := cmp.Diff(desired.Object, actual.Object); diff != "" { + t.Errorf("replacement mismatch (-want +got):\n%s", diff) + } + reapplied, err := apply.ApplyResource(t.Context(), client, test.gvr, desired) + if err != nil { + t.Fatal(err) + } + if changes := apply.Diff(reapplied.Before, reapplied.After); len(changes) != 0 { + t.Errorf("reapplying changed the fake resource: %+v", changes) + } + }) + } +} diff --git a/internal/kubernetes/fake/fake.go b/internal/kubernetes/fake/fake.go index f86360cfd..2ffac2fd9 100644 --- a/internal/kubernetes/fake/fake.go +++ b/internal/kubernetes/fake/fake.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io/fs" - "maps" "os" "path/filepath" "strings" @@ -358,62 +357,6 @@ func NewDynamicClient(scheme *runtime.Scheme) *dynfake.FakeDynamicClient { return true, modified, nil }) - client.PrependReactor("patch", "*", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) { - patchAction, ok := action.(k8stesting.PatchAction) - if !ok { - return false, nil, nil - } - - if patchAction.GetPatchType() != types.ApplyPatchType { - return false, nil, nil - } - - gvr := patchAction.GetResource() - ns := patchAction.GetNamespace() - name := patchAction.GetName() - - desired := &unstructured.Unstructured{} - if err := json.Unmarshal(patchAction.GetPatch(), &desired.Object); err != nil { - return true, nil, fmt.Errorf("unmarshaling apply patch: %w", err) - } - - existing, err := client.Tracker().Get(gvr, ns, name) - if err != nil { - if err := client.Tracker().Create(gvr, desired, ns); err != nil { - return true, nil, fmt.Errorf("creating object via apply: %w", err) - } - return true, desired, nil - } - - existingUnstr, ok := existing.(*unstructured.Unstructured) - if !ok { - return true, nil, fmt.Errorf("expected *unstructured.Unstructured, got %T", existing) - } - - merged := existingUnstr.DeepCopy() - for k, v := range desired.Object { - if k == "metadata" { - // Merge metadata rather than replacing it wholesale. - desiredMeta, ok1 := v.(map[string]any) - existingMeta, ok2 := merged.Object["metadata"].(map[string]any) - if ok1 && ok2 { - maps.Copy(existingMeta, desiredMeta) - merged.Object["metadata"] = existingMeta - } else { - merged.Object[k] = v - } - } else { - merged.Object[k] = v - } - } - - if err := client.Tracker().Update(gvr, merged, ns); err != nil { - return true, nil, fmt.Errorf("updating object via apply: %w", err) - } - - return true, merged, nil - }) - return client }