diff --git a/api/v1alpha1/irsa_types.go b/api/v1alpha1/irsa_types.go index 7f846c6..5145697 100644 --- a/api/v1alpha1/irsa_types.go +++ b/api/v1alpha1/irsa_types.go @@ -17,8 +17,11 @@ limitations under the License. package v1alpha1 import ( + "slices" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ) const ( @@ -55,6 +58,18 @@ type IRSAServiceAccount struct { Namespaces []string `json:"namespaces,omitempty"` } +// NamespacedNameList returns a slice of types.NamespacedName constructed from the Name and Namespace settings. +func (sa *IRSAServiceAccount) NamespacedNameList() []types.NamespacedName { + namespacedName := make([]types.NamespacedName, len(sa.Namespaces)) + for i, ns := range sa.Namespaces { + namespacedName[i] = types.NamespacedName{ + Name: sa.Name, + Namespace: ns, + } + } + return namespacedName +} + // IamRole represents the IAM role configuration type IamRole struct { // Name represents the name of the IAM role. @@ -64,6 +79,60 @@ type IamRole struct { // IRSAStatus defines the observed state of IRSA. type IRSAStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` + // Inventory of applied service resources + ServiceAccounts StatusServiceAccountList `json:"serviceAccounts,omitempty"` +} + +type StatusServiceAccountList []IRSANamespacedNameWithTags + +func (s *StatusServiceAccountList) IsExist(nsNames types.NamespacedName) bool { + return slices.ContainsFunc(*s, func(sa IRSANamespacedNameWithTags) bool { + return sa.Name == nsNames.Name && sa.Name == nsNames.Namespace + }) +} + +// Append adds a new IRSANamespacedNameWithTags to the StatusServiceAccountList. +// If the provided NamespacedName already exists in the list, it will be ignored. +func (s *StatusServiceAccountList) Append(nsNames types.NamespacedName) { + *s = append(*s, IRSANamespacedNameWithTags{ + Name: nsNames.Name, + Namespace: nsNames.Namespace, + }, + ) +} + +// Delete removes an IRSANamespacedNameWithTags from the StatusServiceAccountList +// that matches the provided NamespacedName. If the provided NamespacedName does +// not exist in the list, the method does nothing. +func (s *StatusServiceAccountList) Delete(nsNames types.NamespacedName) { + index := slices.IndexFunc(*s, func(sa IRSANamespacedNameWithTags) bool { + return sa.Name == nsNames.Name && sa.Namespace == nsNames.Namespace + }) + if index != -1 { + *s = slices.Delete(*s, index, index+1) + } +} + +// IRSANamespacedNameWithTags is like a types.NamespacedName with JSON tags +type IRSANamespacedNameWithTags struct { + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +func (s *IRSAStatus) ServiceNamespacedNameList() []types.NamespacedName { + namespacedNameList := make([]types.NamespacedName, len(s.ServiceAccounts)) + for i, n := range s.ServiceAccounts { + namespacedNameList[i] = types.NamespacedName{ + Name: n.Name, + Namespace: n.Namespace, + } + } + return namespacedNameList +} + +// GetIRSAStatusServiceAccounts returns a pointer to the ServiceAccount slice +func (in *IRSA) GetIRSAStatusServiceAccounts() *StatusServiceAccountList { + return &in.Status.ServiceAccounts } // GetIRSAStatusConditions returns a pointer to the Conditions slice @@ -93,11 +162,38 @@ func IRSAStatusNotReady(irsa IRSA, reason, message string) IRSA { return irsa } +func IRSAStatusSetServiceAccount(irsa IRSA, namespacedNames []types.NamespacedName) IRSA { + for _, namespacedName := range namespacedNames { + setStatusServiceAccounts(irsa.GetIRSAStatusServiceAccounts(), namespacedName) + } + return irsa +} + +func IRSAStatusRemoveServiceAccount(irsa IRSA, namespacedNames []types.NamespacedName) IRSA { + for _, namespacedName := range namespacedNames { + removeStatusServiceAccounts(irsa.GetIRSAStatusServiceAccounts(), namespacedName) + } + return irsa +} + +func setStatusServiceAccounts(s *StatusServiceAccountList, namespacedName types.NamespacedName) { + if !s.IsExist(namespacedName) { + s.Append(namespacedName) + } +} + +func removeStatusServiceAccounts(s *StatusServiceAccountList, namespacedName types.NamespacedName) { + if s.IsExist(namespacedName) { + s.Append(namespacedName) + } +} + type IRSAReason string const ( IRSAReasonFailedRoleUpdate IRSAReason = "IRSAFailedRoleUpdate" IRSAReasonFailedK8sApply IRSAReason = "IRSAFailedApplyingResources" + IRSAReasonFailedK8sCleanUp IRSAReason = "IRSAFailedDeletingResources" IRSAReasonReady IRSAReason = "IRSAReady" ) diff --git a/api/v1alpha1/irsa_types_test.go b/api/v1alpha1/irsa_types_test.go new file mode 100644 index 0000000..cadf3fa --- /dev/null +++ b/api/v1alpha1/irsa_types_test.go @@ -0,0 +1,85 @@ +package v1alpha1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/types" +) + +func TestStatusServiceAccountList_Append(t *testing.T) { + tests := []struct { + name string + initial StatusServiceAccountList + toAppend types.NamespacedName + expected StatusServiceAccountList + }{ + { + name: "Append new item", + initial: StatusServiceAccountList{ + {Name: "existing", Namespace: "default"}, + }, + toAppend: types.NamespacedName{Name: "new", Namespace: "default"}, + expected: StatusServiceAccountList{ + {Name: "existing", Namespace: "default"}, + {Name: "new", Namespace: "default"}, + }, + }, + { + name: "Append existing item", + initial: StatusServiceAccountList{ + {Name: "existing", Namespace: "default"}, + }, + toAppend: types.NamespacedName{Name: "existing", Namespace: "default"}, + expected: StatusServiceAccountList{ + {Name: "existing", Namespace: "default"}, + {Name: "existing", Namespace: "default"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.initial.Append(tt.toAppend) + assert.Equal(t, tt.expected, tt.initial) + }) + } +} + +func TestStatusServiceAccountList_Delete(t *testing.T) { + tests := []struct { + name string + initial StatusServiceAccountList + toDelete types.NamespacedName + expected StatusServiceAccountList + }{ + { + name: "Delete existing item", + initial: StatusServiceAccountList{ + {Name: "existing", Namespace: "default"}, + {Name: "todelete", Namespace: "default"}, + }, + toDelete: types.NamespacedName{Name: "todelete", Namespace: "default"}, + expected: StatusServiceAccountList{ + {Name: "existing", Namespace: "default"}, + }, + }, + { + name: "Delete non-existing item", + initial: StatusServiceAccountList{ + {Name: "existing", Namespace: "default"}, + }, + toDelete: types.NamespacedName{Name: "nonexisting", Namespace: "default"}, + expected: StatusServiceAccountList{ + {Name: "existing", Namespace: "default"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.initial.Delete(tt.toDelete) + assert.Equal(t, tt.expected, tt.initial) + }) + } +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index cbf5a8a..65e1d9d 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -100,6 +100,21 @@ func (in *IRSAList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IRSANamespacedNameWithTags) DeepCopyInto(out *IRSANamespacedNameWithTags) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IRSANamespacedNameWithTags. +func (in *IRSANamespacedNameWithTags) DeepCopy() *IRSANamespacedNameWithTags { + if in == nil { + return nil + } + out := new(IRSANamespacedNameWithTags) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IRSAServiceAccount) DeepCopyInto(out *IRSAServiceAccount) { *out = *in @@ -249,6 +264,11 @@ func (in *IRSAStatus) DeepCopyInto(out *IRSAStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ServiceAccounts != nil { + in, out := &in.ServiceAccounts, &out.ServiceAccounts + *out = make(StatusServiceAccountList, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IRSAStatus. @@ -290,3 +310,22 @@ func (in *S3Discovery) DeepCopy() *S3Discovery { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in StatusServiceAccountList) DeepCopyInto(out *StatusServiceAccountList) { + { + in := &in + *out = make(StatusServiceAccountList, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatusServiceAccountList. +func (in StatusServiceAccountList) DeepCopy() StatusServiceAccountList { + if in == nil { + return nil + } + out := new(StatusServiceAccountList) + in.DeepCopyInto(out) + return *out +} diff --git a/charts/irsa-manager/crds/irsa-crd.yaml b/charts/irsa-manager/crds/irsa-crd.yaml index 62a692c..708bac6 100644 --- a/charts/irsa-manager/crds/irsa-crd.yaml +++ b/charts/irsa-manager/crds/irsa-crd.yaml @@ -152,6 +152,21 @@ spec: - type type: object type: array + serviceAccounts: + description: Inventory of applied service resources + items: + description: IRSANamespacedNameWithTags is like a types.NamespacedName + with JSON tags + properties: + name: + type: string + namespace: + type: string + required: + - name + - namespace + type: object + type: array type: object type: object served: true diff --git a/config/crd/bases/irsa-manager.kkb0318.github.io_irsas.yaml b/config/crd/bases/irsa-manager.kkb0318.github.io_irsas.yaml index 057267e..df40c16 100644 --- a/config/crd/bases/irsa-manager.kkb0318.github.io_irsas.yaml +++ b/config/crd/bases/irsa-manager.kkb0318.github.io_irsas.yaml @@ -153,6 +153,21 @@ spec: - type type: object type: array + serviceAccounts: + description: Inventory of applied service resources + items: + description: IRSANamespacedNameWithTags is like a types.NamespacedName + with JSON tags + properties: + name: + type: string + namespace: + type: string + required: + - name + - namespace + type: object + type: array type: object type: object served: true diff --git a/docs/api.md b/docs/api.md index aca1086..3d29967 100644 --- a/docs/api.md +++ b/docs/api.md @@ -51,6 +51,8 @@ IRSA is the Schema for the irsas API + + #### IRSAServiceAccount diff --git a/internal/controller/irsa_controller.go b/internal/controller/irsa_controller.go index af9e4a0..e86c30f 100644 --- a/internal/controller/irsa_controller.go +++ b/internal/controller/irsa_controller.go @@ -25,6 +25,7 @@ import ( "github.com/kkb0318/irsa-manager/internal/issuer" "github.com/kkb0318/irsa-manager/internal/kubernetes" "github.com/kkb0318/irsa-manager/internal/manifests" + "github.com/kkb0318/irsa-manager/internal/utils" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -118,8 +119,6 @@ func (r *IRSAReconciler) reconcileDelete(ctx context.Context, obj *irsav1alpha1. if !obj.Spec.Cleanup { return nil } - serviceAccount := obj.Spec.ServiceAccount - kubeHandler := handler.NewKubernetesHandler(kubeClient) roleManager := awsclient.RoleManager{ RoleName: obj.Spec.IamRole.Name, Policies: obj.Spec.IamPolicies, @@ -131,15 +130,8 @@ func (r *IRSAReconciler) reconcileDelete(ctx context.Context, obj *irsav1alpha1. if err != nil { return err } - for _, ns := range serviceAccount.Namespaces { - sa := manifests.NewServiceAccountBuilder().Build(types.NamespacedName{ - Name: serviceAccount.Name, - Namespace: ns, - }) - kubeHandler.Append(sa) - - } - err = kubeHandler.DeleteAll(ctx) + deleted, err := cleanupKubernetesResources(ctx, kubeClient, obj.Spec.ServiceAccount.NamespacedNameList()) + *obj = irsav1alpha1.IRSAStatusSetServiceAccount(*obj, deleted) if err != nil { return err } @@ -196,24 +188,46 @@ func (r *IRSAReconciler) reconcile(ctx context.Context, obj *irsav1alpha1.IRSA, } kubeHandler := handler.NewKubernetesHandler(kubeClient) - - for _, ns := range serviceAccount.Namespaces { - sa := manifests.NewServiceAccountBuilder().WithIRSAAnnotation(roleManager).Build(types.NamespacedName{ - Name: serviceAccount.Name, - Namespace: ns, - }) + for _, namespacedName := range serviceAccount.NamespacedNameList() { + sa := manifests.NewServiceAccountBuilder().WithIRSAAnnotation(roleManager).Build(namespacedName) kubeHandler.Append(sa) } - err = kubeHandler.ApplyAll(ctx) + applied, err := kubeHandler.ApplyAll(ctx) + *obj = irsav1alpha1.IRSAStatusSetServiceAccount(*obj, applied) if err != nil { e = err reason = irsav1alpha1.IRSAReasonFailedK8sApply return err } + + deleted, err := cleanupKubernetesResources( + ctx, + kubeClient, + utils.DiffNamespacedNames(obj.Status.ServiceNamespacedNameList(), serviceAccount.NamespacedNameList()), + ) + *obj = irsav1alpha1.IRSAStatusSetServiceAccount(*obj, deleted) + if err != nil { + e = err + reason = irsav1alpha1.IRSAReasonFailedK8sCleanUp + return err + } *obj = irsav1alpha1.IRSAStatusReady(*obj, string(irsav1alpha1.IRSAReasonReady), "successfully setup resources") return nil } +func cleanupKubernetesResources(ctx context.Context, client *kubernetes.KubernetesClient, nsNames []types.NamespacedName) ([]types.NamespacedName, error) { + kubeHandler := handler.NewKubernetesHandler(client) + for _, namespacedName := range nsNames { + sa := manifests.NewServiceAccountBuilder().Build(namespacedName) + kubeHandler.Append(sa) + } + deleted, err := kubeHandler.DeleteAll(ctx) + if err != nil { + return deleted, err + } + return deleted, nil +} + // SetupWithManager sets up the controller with the Manager. func (r *IRSAReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/internal/controller/irsa_controller_test.go b/internal/controller/irsa_controller_test.go index d01b8ae..3e70e13 100644 --- a/internal/controller/irsa_controller_test.go +++ b/internal/controller/irsa_controller_test.go @@ -17,6 +17,7 @@ limitations under the License. package controller import ( + "context" "fmt" . "github.com/onsi/ginkgo/v2" @@ -80,10 +81,6 @@ var _ = Describe("IRSA Controller", func() { NamespacedName: typeNamespacedName, }) Expect(err).NotTo(HaveOccurred()) - _, err = r.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) for _, expect := range expected { checkExist(expect) } @@ -101,7 +98,7 @@ var _ = Describe("IRSA Controller", func() { }, }, { - name: "error", + name: "AWS API Error Case", obj: &irsav1alpha1.IRSA{ ObjectMeta: metav1.ObjectMeta{ Name: "test-resource2", @@ -169,6 +166,75 @@ var _ = Describe("IRSA Controller", func() { } }, }, + { + name: "should update serviceaccount successfully", + obj: &irsav1alpha1.IRSA{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-resource3", + Namespace: "default", + }, + Spec: irsav1alpha1.IRSASpec{ + Cleanup: true, + ServiceAccount: irsav1alpha1.IRSAServiceAccount{ + Name: "sa-3", + Namespaces: []string{ + "kube-system", + }, + }, + }, + }, + irsaSetupObj: newMockIRSASetup(), + f: func(r *IRSAReconciler, obj *irsav1alpha1.IRSA) { + expected := []expectedResource{ + { + NamespacedName: types.NamespacedName{Name: "sa-3", Namespace: "kube-system"}, + f: newServiceAccount, + }, + { + NamespacedName: types.NamespacedName{Name: "sa-3", Namespace: "default"}, + f: newServiceAccount, + }, + } + + By("Reconciling the created resource") + typeNamespacedName := types.NamespacedName{ + Name: obj.Name, + Namespace: obj.Namespace, + } + _, err := r.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + checkExist( + expectedResource{ + NamespacedName: types.NamespacedName{Name: "sa-3", Namespace: "kube-system"}, + f: newServiceAccount, + }, + ) + f := createCallBack(ctx, r, typeNamespacedName, obj) + + By("Add Namespace 'default'") + f(obj.Spec.ServiceAccount.Name, []string{"default", "kube-system"}) + By("Remove Namespace 'kube-system'") + f(obj.Spec.ServiceAccount.Name, []string{"default"}) + checkNoExist(expectedResource{ + NamespacedName: types.NamespacedName{Name: "sa-3", Namespace: "kube-system"}, + f: newServiceAccount, + }) + + By("removing the custom resource for the Kind") + Eventually(func() error { + return k8sClient.Delete(ctx, obj) + }, timeout).Should(Succeed()) + _, err = r.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(Not(HaveOccurred())) + for _, expect := range expected { + checkNoExist(expect) + } + }, + }, } for _, tt := range tests { It(tt.name, func() { @@ -231,3 +297,31 @@ func newMockIRSASetup() *irsav1alpha1.IRSASetup { }, } } + +func createCallBack(ctx context.Context, r *IRSAReconciler, typeNamespacedName types.NamespacedName, obj *irsav1alpha1.IRSA) func(name string, namespaces []string) { + return func(name string, namespaces []string) { + fixNamespacesAndReconcile(ctx, r, typeNamespacedName, obj, name, namespaces) + } +} + +func fixNamespacesAndReconcile(ctx context.Context, r *IRSAReconciler, typeNamespacedName types.NamespacedName, obj *irsav1alpha1.IRSA, name string, namespaces []string) { + Expect(k8sClient.Get(ctx, typeNamespacedName, obj)).NotTo(HaveOccurred()) + Eventually(func() error { + obj.Spec.ServiceAccount.Namespaces = namespaces + return k8sClient.Update(ctx, obj) + }, timeout).Should(Succeed()) + _, err := r.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + expected := []expectedResource{} + for _, ns := range namespaces { + expected = append(expected, expectedResource{ + NamespacedName: types.NamespacedName{Name: name, Namespace: ns}, + f: newServiceAccount, + }) + } + for _, e := range expected { + checkExist(e) + } +} diff --git a/internal/controller/irsasetup_controller.go b/internal/controller/irsasetup_controller.go index a9f4a55..184c48c 100644 --- a/internal/controller/irsasetup_controller.go +++ b/internal/controller/irsasetup_controller.go @@ -150,7 +150,7 @@ func (r *IRSASetupReconciler) reconcileDelete(ctx context.Context, obj *irsav1al for _, r := range webhookSetup.Resources() { kubeHandler.Append(r) } - err = kubeHandler.DeleteAll(ctx) + _, err = kubeHandler.DeleteAll(ctx) if err != nil { return err } @@ -233,7 +233,7 @@ func reconcileSelfhosted(ctx context.Context, obj *irsav1alpha1.IRSASetup, awsCl return err } if forceUpdate { - err = kubeHandlerForOidc.ApplyAll(ctx) + _, err = kubeHandlerForOidc.ApplyAll(ctx) } else { err = kubeHandlerForOidc.CreateAll(ctx) } @@ -247,7 +247,7 @@ func reconcileSelfhosted(ctx context.Context, obj *irsav1alpha1.IRSASetup, awsCl for _, r := range webhookSetup.Resources() { kubeHandlerForWebhook.Append(r) } - err = kubeHandlerForWebhook.ApplyAll(ctx) + _, err = kubeHandlerForWebhook.ApplyAll(ctx) if err != nil { e = err reason = irsav1alpha1.SelfHostedReasonFailedWebhook diff --git a/internal/handler/kubernetes.go b/internal/handler/kubernetes.go index 61906dd..a0f6a07 100644 --- a/internal/handler/kubernetes.go +++ b/internal/handler/kubernetes.go @@ -6,6 +6,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -39,24 +40,28 @@ func (k *KubernetesHandler) CreateAll(ctx context.Context) error { return nil } -func (k *KubernetesHandler) ApplyAll(ctx context.Context) error { +func (k *KubernetesHandler) ApplyAll(ctx context.Context) ([]types.NamespacedName, error) { + applied := []types.NamespacedName{} for _, obj := range k.objs { err := k.client.Apply(ctx, obj) if err != nil { - return err + return applied, err } + applied = append(applied, client.ObjectKeyFromObject(obj)) } - return nil + return applied, nil } -func (k *KubernetesHandler) DeleteAll(ctx context.Context) error { +func (k *KubernetesHandler) DeleteAll(ctx context.Context) ([]types.NamespacedName, error) { + deleted := []types.NamespacedName{} for _, obj := range k.objs { err := k.client.Delete(ctx, obj, DeleteOptions{ DeletionPropagation: metav1.DeletePropagationBackground, }) if err != nil { - return err + return deleted, err } + deleted = append(deleted, client.ObjectKeyFromObject(obj)) } - return nil + return deleted, nil } diff --git a/internal/utils/diff.go b/internal/utils/diff.go new file mode 100644 index 0000000..57aec12 --- /dev/null +++ b/internal/utils/diff.go @@ -0,0 +1,23 @@ +package utils + +import ( + "k8s.io/apimachinery/pkg/types" +) + +// DiffNamespacedNames returns the namespaced names that are in target but not in reference. +func DiffNamespacedNames(target, reference []types.NamespacedName) []types.NamespacedName { + referenceSet := make(map[types.NamespacedName]struct{}) + + for _, item := range reference { + referenceSet[item] = struct{}{} + } + + diff := []types.NamespacedName{} + for _, item := range target { + if _, exists := referenceSet[item]; !exists { + diff = append(diff, item) + } + } + + return diff +} diff --git a/internal/utils/diff_test.go b/internal/utils/diff_test.go new file mode 100644 index 0000000..dfe2433 --- /dev/null +++ b/internal/utils/diff_test.go @@ -0,0 +1,87 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/types" +) + +func TestDiffNamespacedNames(t *testing.T) { + tests := []struct { + name string + target []types.NamespacedName + reference []types.NamespacedName + expected []types.NamespacedName + }{ + { + "NoDifference", + []types.NamespacedName{ + {Namespace: "default", Name: "resource1"}, + {Namespace: "default", Name: "resource2"}, + }, + []types.NamespacedName{ + {Namespace: "default", Name: "resource1"}, + {Namespace: "default", Name: "resource2"}, + }, + []types.NamespacedName{}, + }, + { + "SomeDifference", + []types.NamespacedName{ + {Namespace: "default", Name: "resource1"}, + {Namespace: "default", Name: "resource3"}, + }, + []types.NamespacedName{ + {Namespace: "default", Name: "resource1"}, + {Namespace: "default", Name: "resource2"}, + }, + []types.NamespacedName{ + {Namespace: "default", Name: "resource3"}, + }, + }, + { + "AllDifferent", + []types.NamespacedName{ + {Namespace: "default", Name: "resource1"}, + {Namespace: "default", Name: "resource2"}, + }, + []types.NamespacedName{ + {Namespace: "other", Name: "resource3"}, + {Namespace: "other", Name: "resource4"}, + }, + []types.NamespacedName{ + {Namespace: "default", Name: "resource1"}, + {Namespace: "default", Name: "resource2"}, + }, + }, + { + "EmptyTarget", + []types.NamespacedName{}, + []types.NamespacedName{ + {Namespace: "default", Name: "resource1"}, + {Namespace: "default", Name: "resource2"}, + }, + []types.NamespacedName{}, + }, + { + "EmptyReference", + []types.NamespacedName{ + {Namespace: "default", Name: "resource1"}, + {Namespace: "default", Name: "resource2"}, + }, + []types.NamespacedName{}, + []types.NamespacedName{ + {Namespace: "default", Name: "resource1"}, + {Namespace: "default", Name: "resource2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DiffNamespacedNames(tt.target, tt.reference) + assert.Equal(t, tt.expected, result) + }) + } +}