From 6a33bffb65b6d6811beeebe42b4f606ec0b3f386 Mon Sep 17 00:00:00 2001 From: "M. Essam" Date: Fri, 28 Mar 2025 09:55:41 +0200 Subject: [PATCH] Add unit tests to new controllers and fix minor bugs (#12) --- api/v1/nbgroup_types.go | 5 +- api/v1/nbpolicy_types.go | 7 +- api/v1/nbresource_types.go | 22 +- api/v1/nbroutingpeer_types.go | 5 +- cmd/main.go | 6 +- .../controller/nbgroup_controller_test.go | 357 ++++++- internal/controller/nbpolicy_controller.go | 38 +- .../controller/nbpolicy_controller_test.go | 623 ++++++++++- internal/controller/nbresource_controller.go | 93 +- .../controller/nbresource_controller_test.go | 681 +++++++++++- .../controller/nbroutingpeer_controller.go | 35 +- .../nbroutingpeer_controller_test.go | 988 +++++++++++++++++- internal/controller/service_controller.go | 98 +- .../controller/service_controller_test.go | 464 +++++++- internal/webhook/v1/nbgroup_webhook.go | 8 +- internal/webhook/v1/nbgroup_webhook_test.go | 165 ++- internal/webhook/v1/nbresource_webhook.go | 12 +- .../webhook/v1/nbresource_webhook_test.go | 130 ++- internal/webhook/v1/nbroutingpeer_webhook.go | 14 +- .../webhook/v1/nbroutingpeer_webhook_test.go | 248 ++++- .../webhook/v1/nbsetupkey_webhook_test.go | 96 +- internal/webhook/v1/webhook_suite_test.go | 6 +- 22 files changed, 3788 insertions(+), 313 deletions(-) diff --git a/api/v1/nbgroup_types.go b/api/v1/nbgroup_types.go index 5878196..f6610f3 100644 --- a/api/v1/nbgroup_types.go +++ b/api/v1/nbgroup_types.go @@ -1,8 +1,7 @@ package v1 import ( - "slices" - + "github.com/netbirdio/kubernetes-operator/internal/util" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -23,7 +22,7 @@ type NBGroupStatus struct { // Equal returns if NBGroupStatus is equal to this one func (a NBGroupStatus) Equal(b NBGroupStatus) bool { - return a.GroupID == b.GroupID && slices.Equal(a.Conditions, b.Conditions) + return (a.GroupID == b.GroupID || (a.GroupID != nil && b.GroupID != nil && *a.GroupID == *b.GroupID)) && util.Equivalent(a.Conditions, b.Conditions) } // +kubebuilder:object:root=true diff --git a/api/v1/nbpolicy_types.go b/api/v1/nbpolicy_types.go index 60027c3..b05d32d 100644 --- a/api/v1/nbpolicy_types.go +++ b/api/v1/nbpolicy_types.go @@ -1,8 +1,7 @@ package v1 import ( - "slices" - + "github.com/netbirdio/kubernetes-operator/internal/util" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -50,8 +49,8 @@ func (a NBPolicyStatus) Equal(b NBPolicyStatus) bool { return a.TCPPolicyID == b.TCPPolicyID && a.UDPPolicyID == b.UDPPolicyID && a.LastUpdatedAt == b.LastUpdatedAt && - slices.Equal(a.ManagedServiceList, b.ManagedServiceList) && - slices.Equal(a.Conditions, b.Conditions) + util.Equivalent(a.ManagedServiceList, b.ManagedServiceList) && + util.Equivalent(a.Conditions, b.Conditions) } // +kubebuilder:object:root=true diff --git a/api/v1/nbresource_types.go b/api/v1/nbresource_types.go index a1957d4..65730c7 100644 --- a/api/v1/nbresource_types.go +++ b/api/v1/nbresource_types.go @@ -1,8 +1,7 @@ package v1 import ( - "slices" - + "github.com/netbirdio/kubernetes-operator/internal/util" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -24,6 +23,17 @@ type NBResourceSpec struct { UDPPorts []int32 `json:"udpPorts,omitempty"` } +// Equal returns if NBResource is equal to this one +func (a NBResourceSpec) Equal(b NBResourceSpec) bool { + return a.Name == b.Name && + a.NetworkID == b.NetworkID && + a.Address == b.Address && + util.Equivalent(a.Groups, b.Groups) && + a.PolicyName == b.PolicyName && + util.Equivalent(a.TCPPorts, b.TCPPorts) && + util.Equivalent(a.UDPPorts, b.UDPPorts) +} + // NBResourceStatus defines the observed state of NBResource. type NBResourceStatus struct { // +optional @@ -44,10 +54,10 @@ type NBResourceStatus struct { func (a NBResourceStatus) Equal(b NBResourceStatus) bool { return a.NetworkResourceID == b.NetworkResourceID && a.PolicyName == b.PolicyName && - slices.Equal(a.TCPPorts, b.TCPPorts) && - slices.Equal(a.UDPPorts, b.UDPPorts) && - slices.Equal(a.Groups, b.Groups) && - slices.Equal(a.Conditions, b.Conditions) + util.Equivalent(a.TCPPorts, b.TCPPorts) && + util.Equivalent(a.UDPPorts, b.UDPPorts) && + util.Equivalent(a.Groups, b.Groups) && + util.Equivalent(a.Conditions, b.Conditions) } // +kubebuilder:object:root=true diff --git a/api/v1/nbroutingpeer_types.go b/api/v1/nbroutingpeer_types.go index 0fd7f23..42c838d 100644 --- a/api/v1/nbroutingpeer_types.go +++ b/api/v1/nbroutingpeer_types.go @@ -1,8 +1,7 @@ package v1 import ( - "slices" - + "github.com/netbirdio/kubernetes-operator/internal/util" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -40,7 +39,7 @@ func (a NBRoutingPeerStatus) Equal(b NBRoutingPeerStatus) bool { return a.NetworkID == b.NetworkID && a.SetupKeyID == b.SetupKeyID && a.RouterID == b.RouterID && - slices.Equal(a.Conditions, b.Conditions) + util.Equivalent(a.Conditions, b.Conditions) } // +kubebuilder:object:root=true diff --git a/cmd/main.go b/cmd/main.go index 6252d3d..a130f4c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -263,17 +263,17 @@ func main() { } if enableWebhooks { - if err = webhooknetbirdiov1.SetupNBResourceWebhookWithManager(mgr, managementURL, netbirdAPIKey); err != nil { + if err = webhooknetbirdiov1.SetupNBResourceWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "NBResource") os.Exit(1) } - if err = webhooknetbirdiov1.SetupNBRoutingPeerWebhookWithManager(mgr, managementURL, netbirdAPIKey); err != nil { + if err = webhooknetbirdiov1.SetupNBRoutingPeerWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "NBRoutingPeer") os.Exit(1) } - if err = webhooknetbirdiov1.SetupNBGroupWebhookWithManager(mgr, managementURL, netbirdAPIKey); err != nil { + if err = webhooknetbirdiov1.SetupNBGroupWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "NBGroup") os.Exit(1) } diff --git a/internal/controller/nbgroup_controller_test.go b/internal/controller/nbgroup_controller_test.go index 60ea7ef..bff7549 100644 --- a/internal/controller/nbgroup_controller_test.go +++ b/internal/controller/nbgroup_controller_test.go @@ -2,16 +2,21 @@ package controller import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/reconcile" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" + "github.com/netbirdio/kubernetes-operator/internal/util" + netbird "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" ) var _ = Describe("NBGroup Controller", func() { @@ -22,49 +27,341 @@ var _ = Describe("NBGroup Controller", func() { typeNamespacedName := types.NamespacedName{ Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed + Namespace: "default", } - nbgroup := &netbirdiov1.NBGroup{} + + var netbirdClient *netbird.Client + var mux *http.ServeMux + var server *httptest.Server + var nbGroup netbirdiov1.NBGroup BeforeEach(func() { - Skip("Not implemented yet") - By("creating the custom resource for the Kind NBGroup") - err := k8sClient.Get(ctx, typeNamespacedName, nbgroup) - if err != nil && errors.IsNotFound(err) { - resource := &netbirdiov1.NBGroup{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", + mux = &http.ServeMux{} + server = httptest.NewServer(mux) + netbirdClient = netbird.New(server.URL, "ABC") + + err := k8sClient.Get(ctx, typeNamespacedName, &nbGroup) + if err == nil { + deleteErr := k8sClient.Delete(ctx, &nbGroup) + Expect(deleteErr).NotTo(HaveOccurred()) + } + if err == nil || errors.IsNotFound(err) { + nbGroup = netbirdiov1.NBGroup{ + ObjectMeta: v1.ObjectMeta{ + Name: resourceName, + Namespace: typeNamespacedName.Namespace, + Finalizers: []string{"netbird.io/group-cleanup"}, + }, + Spec: netbirdiov1.NBGroupSpec{ + Name: resourceName, }, - // TODO(user): Specify other spec details if needed. } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + err = k8sClient.Create(ctx, &nbGroup) + Expect(err).NotTo(HaveOccurred()) } }) AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. + server.Close() resource := &netbirdiov1.NBGroup{} err := k8sClient.Get(ctx, typeNamespacedName, resource) + if errors.IsNotFound(err) { + return + } Expect(err).NotTo(HaveOccurred()) - - By("Cleanup the specific resource instance NBGroup") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - }) - It("should successfully reconcile the resource", func() { - Skip("Not implemented yet") - By("Reconciling the created resource") - controllerReconciler := &NBGroupReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + if len(resource.Finalizers) > 0 { + resource.Finalizers = nil + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) } - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, + if resource.DeletionTimestamp == nil { + By("Cleanup the specific resource instance NBGroup") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + When("Group doesn't exist", func() { + It("should create group", func() { + By("Reconciling the created resource") + controllerReconciler := &NBGroupReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + } + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _, err := w.Write([]byte("[]")) + Expect(err).NotTo(HaveOccurred()) + } else { + resp := api.Group{ + Id: "Test", + Name: resourceName, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + err = k8sClient.Get(ctx, typeNamespacedName, &nbGroup) + Expect(err).NotTo(HaveOccurred()) + Expect(nbGroup.Status.GroupID).NotTo(BeNil()) + Expect(*nbGroup.Status.GroupID).To(Equal("Test")) + Expect(nbGroup.Status.Conditions).To(HaveLen(1)) + Expect(nbGroup.Status.Conditions[0].Status).To(BeEquivalentTo(v1.ConditionTrue)) + Expect(nbGroup.Status.Conditions[0].Type).To(Equal(netbirdiov1.NBSetupKeyReady)) + }) + }) + + When("Group already exists", func() { + It("should use existing group", func() { + By("Reconciling the created resource") + controllerReconciler := &NBGroupReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + } + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + resp := []api.Group{ + { + Id: "Test", + Name: resourceName, + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + err = k8sClient.Get(ctx, typeNamespacedName, &nbGroup) + Expect(err).NotTo(HaveOccurred()) + Expect(nbGroup.Status.GroupID).NotTo(BeNil()) + Expect(*nbGroup.Status.GroupID).To(Equal("Test")) + Expect(nbGroup.Status.Conditions).To(HaveLen(1)) + Expect(nbGroup.Status.Conditions[0].Status).To(BeEquivalentTo(v1.ConditionTrue)) + Expect(nbGroup.Status.Conditions[0].Type).To(Equal(netbirdiov1.NBSetupKeyReady)) + }) + }) + + When("NBGroup is set for deletion", func() { + deleteGroup := func() { + GinkgoHelper() + By("Adding the group ID in status") + nbGroup.Status.GroupID = util.Ptr("Test") + err := k8sClient.Status().Update(ctx, &nbGroup) + Expect(err).NotTo(HaveOccurred()) + + By("Deleting the object") + err = k8sClient.Delete(ctx, &nbGroup) + Expect(err).NotTo(HaveOccurred()) + } + + When("Group is not linked to any resources", func() { + It("should delete group", func() { + deleteGroup() + By("Reconciling the deleting resource") + controllerReconciler := &NBGroupReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + } + + method := "" + mux.HandleFunc("/api/groups/Test", func(w http.ResponseWriter, r *http.Request) { + method = r.Method + _, err := w.Write([]byte("{}")) + Expect(err).NotTo(HaveOccurred()) + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + err = k8sClient.Get(ctx, typeNamespacedName, &nbGroup) + Expect(errors.IsNotFound(err)).To(BeTrue()) + Expect(method).To(Equal(http.MethodDelete)) + }) + }) + + When("Group is linked to other resources", func() { + It("should return error", func() { + deleteGroup() + By("Reconciling the deleting resource") + controllerReconciler := &NBGroupReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + } + + method := "" + mux.HandleFunc("/api/groups/Test", func(w http.ResponseWriter, r *http.Request) { + method = r.Method + w.WriteHeader(400) + _, err := w.Write([]byte(`{"message": "group has been linked to Policy: meow"}`)) + Expect(err).NotTo(HaveOccurred()) + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + err = k8sClient.Get(ctx, typeNamespacedName, &nbGroup) + Expect(errors.IsNotFound(err)).To(BeFalse()) + Expect(method).To(Equal(http.MethodDelete)) + }) + }) + + When("Group already exists in another namespace", func() { + It("Should delete NBGroup after linked failure", func() { + deleteGroup() + otherGroup := &netbirdiov1.NBGroup{ + ObjectMeta: v1.ObjectMeta{ + Name: nbGroup.Name, + Namespace: "kube-system", + }, + Spec: netbirdiov1.NBGroupSpec{ + Name: nbGroup.Spec.Name, + }, + } + Expect(k8sClient.Create(ctx, otherGroup)).To(Succeed()) + + otherGroup.Status.GroupID = nbGroup.Status.GroupID + Expect(k8sClient.Status().Update(ctx, otherGroup)).To(Succeed()) + + By("Reconciling the deleting resource") + controllerReconciler := &NBGroupReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + } + + method := "" + mux.HandleFunc("/api/groups/Test", func(w http.ResponseWriter, r *http.Request) { + method = r.Method + w.WriteHeader(400) + _, err := w.Write([]byte(`{"message": "group has been linked to Policy: meow"}`)) + Expect(err).NotTo(HaveOccurred()) + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + + Expect(err).NotTo(HaveOccurred()) + err = k8sClient.Get(ctx, typeNamespacedName, &nbGroup) + Expect(errors.IsNotFound(err)).To(BeTrue()) + Expect(method).To(Equal(http.MethodDelete)) + }) + }) + }) + + When("Group already exists with different ID", func() { + It("should re-use existing group ID", func() { + controllerReconciler := &NBGroupReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + } + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + resp := []api.Group{ + { + Id: "Test", + Name: resourceName, + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + nbGroup.Status.GroupID = util.Ptr("Toast") + Expect(k8sClient.Status().Update(ctx, &nbGroup)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + err = k8sClient.Get(ctx, typeNamespacedName, &nbGroup) + Expect(err).NotTo(HaveOccurred()) + Expect(nbGroup.Status.GroupID).NotTo(BeNil()) + Expect(*nbGroup.Status.GroupID).To(Equal("Test")) + Expect(nbGroup.Status.Conditions).To(HaveLen(1)) + Expect(nbGroup.Status.Conditions[0].Status).To(BeEquivalentTo(v1.ConditionTrue)) + Expect(nbGroup.Status.Conditions[0].Type).To(Equal(netbirdiov1.NBSetupKeyReady)) + }) + }) + + When("Group deleted from NetBird API", func() { + It("Should requeue and create group on next run", func() { + controllerReconciler := &NBGroupReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + } + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + resp := []api.Group{} + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + if r.Method == http.MethodPost { + resp := api.Group{ + Id: "Test", + Name: resourceName, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + nbGroup.Status.GroupID = util.Ptr("Toast") + Expect(k8sClient.Status().Update(ctx, &nbGroup)).To(Succeed()) + + res, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Requeue).To(BeTrue()) + + err = k8sClient.Get(ctx, typeNamespacedName, &nbGroup) + Expect(err).NotTo(HaveOccurred()) + + Expect(nbGroup.Status.GroupID).To(BeNil()) + Expect(nbGroup.Status.Conditions).To(HaveLen(1)) + Expect(nbGroup.Status.Conditions[0].Status).To(BeEquivalentTo(v1.ConditionFalse)) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, typeNamespacedName, &nbGroup) + Expect(err).NotTo(HaveOccurred()) + + Expect(nbGroup.Status.GroupID).NotTo(BeNil()) + Expect(*nbGroup.Status.GroupID).To(Equal("Test")) + Expect(nbGroup.Status.Conditions).To(HaveLen(1)) + Expect(nbGroup.Status.Conditions[0].Status).To(BeEquivalentTo(v1.ConditionTrue)) }) - Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. }) }) }) diff --git a/internal/controller/nbpolicy_controller.go b/internal/controller/nbpolicy_controller.go index 78f9d36..9c41479 100644 --- a/internal/controller/nbpolicy_controller.go +++ b/internal/controller/nbpolicy_controller.go @@ -35,6 +35,11 @@ var ( errNetBirdAPI = fmt.Errorf("netbird API error") ) +const ( + protocolTCP = "tcp" + protocolUDP = "udp" +) + // getResources get all NBResource objects in policy.status.managedServiceList func (r *NBPolicyReconciler) getResources(ctx context.Context, nbPolicy *netbirdiov1.NBPolicy, logger logr.Logger) ([]netbirdiov1.NBResource, error) { var resourceList []netbirdiov1.NBResource @@ -63,8 +68,8 @@ func (r *NBPolicyReconciler) getResources(ctx context.Context, nbPolicy *netbird // returns map[protocol] => ports, destination group IDs func (r *NBPolicyReconciler) mapResources(ctx context.Context, nbPolicy *netbirdiov1.NBPolicy, resources []netbirdiov1.NBResource, logger logr.Logger) (map[string][]int32, []string, error) { portMapping := map[string]map[int32]interface{}{ - "tcp": make(map[int32]interface{}), - "udp": make(map[int32]interface{}), + protocolTCP: make(map[int32]interface{}), + protocolUDP: make(map[int32]interface{}), } groups, err := r.groupNamesToIDs(ctx, nbPolicy.Spec.DestinationGroups, logger) if err != nil { @@ -77,16 +82,17 @@ func (r *NBPolicyReconciler) mapResources(ctx context.Context, nbPolicy *netbird groups = append(groups, resource.Status.Groups...) for _, p := range resource.Spec.TCPPorts { - portMapping["tcp"][p] = nil + portMapping[protocolTCP][p] = nil } for _, p := range resource.Spec.UDPPorts { - portMapping["udp"][p] = nil + portMapping[protocolUDP][p] = nil } } } ports := make(map[string][]int32) for k, vs := range portMapping { + ports[k] = nil for v := range vs { ports[k] = append(ports[k], v) } @@ -131,7 +137,7 @@ func (r *NBPolicyReconciler) createPolicy(ctx context.Context, nbPolicy *netbird func (r *NBPolicyReconciler) updatePolicy(ctx context.Context, policyID *string, nbPolicy *netbirdiov1.NBPolicy, protocol string, sourceGroupIDs, destinationGroupIDs, ports []string, logger logr.Logger) (*string, bool, error) { policyName := fmt.Sprintf("%s %s", nbPolicy.Spec.Name, strings.ToUpper(protocol)) logger.Info("Updating NetBird Policy", "name", policyName, "description", nbPolicy.Spec.Description, "protocol", protocol, "sources", sourceGroupIDs, "destinations", destinationGroupIDs, "ports", ports, "bidirectional", nbPolicy.Spec.Bidirectional) - policy, err := r.netbird.Policies.Update(ctx, *policyID, api.PutApiPoliciesPolicyIdJSONRequestBody{ + _, err := r.netbird.Policies.Update(ctx, *policyID, api.PutApiPoliciesPolicyIdJSONRequestBody{ Enabled: true, Name: policyName, Description: &nbPolicy.Spec.Description, @@ -163,11 +169,10 @@ func (r *NBPolicyReconciler) updatePolicy(ctx context.Context, policyID *string, policyID = nil requeue = true nbPolicy.Status.Conditions = netbirdiov1.NBConditionFalse("Gone", "Policy deleted from NetBird API") + } else if err != nil { + return nil, false, err } - if err == nil && (policyID == nil || *policy.Id != *policyID) { - policyID = policy.Id - } return policyID, requeue, nil } @@ -192,6 +197,9 @@ func (r *NBPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (r originalPolicy := nbPolicy.DeepCopy() defer func() { + if originalPolicy.DeletionTimestamp != nil && len(nbPolicy.Finalizers) == 0 { + return + } if !originalPolicy.Status.Equal(nbPolicy.Status) { updateErr := r.Client.Status().Update(ctx, &nbPolicy) if updateErr != nil { @@ -207,7 +215,7 @@ func (r *NBPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (r if len(nbPolicy.Finalizers) == 0 { return ctrl.Result{}, nil } - return ctrl.Result{}, r.handleDelete(ctx, nbPolicy, logger) + return ctrl.Result{}, r.handleDelete(ctx, &nbPolicy, logger) } resourceList, err := r.getResources(ctx, &nbPolicy, logger) @@ -244,9 +252,9 @@ func (r *NBPolicyReconciler) syncPolicy(ctx context.Context, nbPolicy *netbirdio for protocol, ports := range portMapping { var policyID *string switch protocol { - case "tcp": + case protocolTCP: policyID = nbPolicy.Status.TCPPolicyID - case "udp": + case protocolUDP: policyID = nbPolicy.Status.UDPPolicyID default: logger.Error(errKubernetesAPI, "Unknown protocol", "protocol", protocol) @@ -306,9 +314,9 @@ func (r *NBPolicyReconciler) syncPolicy(ctx context.Context, nbPolicy *netbirdio } switch protocol { - case "tcp": + case protocolTCP: nbPolicy.Status.TCPPolicyID = policyID - case "udp": + case protocolUDP: nbPolicy.Status.UDPPolicyID = policyID default: logger.Error(errKubernetesAPI, "Unknown protocol", "protocol", protocol) @@ -320,7 +328,7 @@ func (r *NBPolicyReconciler) syncPolicy(ctx context.Context, nbPolicy *netbirdio return requeue, nil } -func (r *NBPolicyReconciler) handleDelete(ctx context.Context, nbPolicy netbirdiov1.NBPolicy, logger logr.Logger) error { +func (r *NBPolicyReconciler) handleDelete(ctx context.Context, nbPolicy *netbirdiov1.NBPolicy, logger logr.Logger) error { if nbPolicy.Status.TCPPolicyID != nil { err := r.netbird.Policies.Delete(ctx, *nbPolicy.Status.TCPPolicyID) if err != nil && !strings.Contains("not found", err.Error()) { @@ -337,7 +345,7 @@ func (r *NBPolicyReconciler) handleDelete(ctx context.Context, nbPolicy netbirdi } if util.Contains(nbPolicy.Finalizers, "netbird.io/cleanup") { nbPolicy.Finalizers = util.Without(nbPolicy.Finalizers, "netbird.io/cleanup") - err := r.Client.Update(ctx, &nbPolicy) + err := r.Client.Update(ctx, nbPolicy) if err != nil { logger.Error(errKubernetesAPI, "Error updating NBPolicy", "err", err) return err diff --git a/internal/controller/nbpolicy_controller_test.go b/internal/controller/nbpolicy_controller_test.go index 729bbd4..65bee87 100644 --- a/internal/controller/nbpolicy_controller_test.go +++ b/internal/controller/nbpolicy_controller_test.go @@ -2,7 +2,12 @@ package controller import ( "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "github.com/go-logr/logr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" @@ -12,59 +17,631 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" + "github.com/netbirdio/kubernetes-operator/internal/util" + netbird "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" + ctrl "sigs.k8s.io/controller-runtime" ) var _ = Describe("NBPolicy Controller", func() { Context("When reconciling a resource", func() { - const resourceName = "test-resource" + var resourceName = "test-resource" ctx := context.Background() typeNamespacedName := types.NamespacedName{ - Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed + Name: resourceName, } nbpolicy := &netbirdiov1.NBPolicy{} + var netbirdClient *netbird.Client + var mux *http.ServeMux + var server *httptest.Server BeforeEach(func() { - Skip("Not implemented yet") + ctrl.SetLogger(logr.New(GinkgoLogr.GetSink())) + mux = &http.ServeMux{} + server = httptest.NewServer(mux) + netbirdClient = netbird.New(server.URL, "ABC") + By("creating the custom resource for the Kind NBPolicy") err := k8sClient.Get(ctx, typeNamespacedName, nbpolicy) if err != nil && errors.IsNotFound(err) { resource := &netbirdiov1.NBPolicy{ ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", + Name: resourceName, + Finalizers: []string{"netbird.io/cleanup"}, + }, + Spec: netbirdiov1.NBPolicySpec{ + Name: "Test", + SourceGroups: []string{"All"}, + Bidirectional: true, }, - // TODO(user): Specify other spec details if needed. } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + nbpolicy = resource } }) AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. resource := &netbirdiov1.NBPolicy{} err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) - By("Cleanup the specific resource instance NBPolicy") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - }) - It("should successfully reconcile the resource", func() { - Skip("Not implemented yet") - By("Reconciling the created resource") - controllerReconciler := &NBPolicyReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + if len(resource.Finalizers) > 0 { + resource.Finalizers = nil + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + } + + By("Cleanup the specific resource instance NBPolicy") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) } - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, + nbresource := &netbirdiov1.NBResource{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "test"}, nbresource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance NBResource") + Expect(k8sClient.Delete(ctx, nbresource)).To(Succeed()) + } + }) + When("Not enough information to create policy", func() { + It("should not create any policy", func() { + controllerReconciler := &NBPolicyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + ClusterName: "Kubernetes", + } + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + resp := []api.Group{ + { + Id: "meow", + Name: "All", + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + When("Enough information to create TCP policy", func() { + It("should create 1 policy", func() { + controllerReconciler := &NBPolicyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + ClusterName: "Kubernetes", + } + + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "meow", + Groups: []string{"test"}, + NetworkID: "test", + Address: "test.default.svc.cluster.local", + PolicyName: resourceName, + TCPPorts: []int32{443}, + }, + } + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + + nbResource.Status = netbirdiov1.NBResourceStatus{ + TCPPorts: []int32{443}, + PolicyName: &resourceName, + Groups: []string{"test"}, + } + Expect(k8sClient.Status().Update(ctx, nbResource)).To(Succeed()) + + nbpolicy.Status.ManagedServiceList = append(nbpolicy.Status.ManagedServiceList, "default/test") + Expect(k8sClient.Status().Update(ctx, nbpolicy)).To(Succeed()) + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + resp := []api.Group{ + { + Id: "meow", + Name: "All", + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + policyCreated := false + mux.HandleFunc("/api/policies", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPost { + var policyReq api.PostApiPoliciesJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + err = json.Unmarshal(bs, &policyReq) + Expect(err).NotTo(HaveOccurred()) + Expect(policyReq.Name).To(Equal("Test TCP")) + Expect(policyReq.Description).To(Or(BeNil(), BeEquivalentTo(util.Ptr("")))) + Expect(policyReq.Enabled).To(BeTrue()) + Expect(policyReq.SourcePostureChecks).To(BeNil()) + Expect(policyReq.Rules).To(HaveLen(1)) + Expect(policyReq.Rules[0].Action).To(BeEquivalentTo(api.PolicyRuleActionAccept)) + Expect(policyReq.Rules[0].Bidirectional).To(BeTrue()) + Expect(policyReq.Rules[0].Description).To(Or(BeNil(), BeEquivalentTo(util.Ptr("")))) + Expect(policyReq.Rules[0].DestinationResource).To(BeNil()) + Expect(policyReq.Rules[0].Destinations).NotTo(BeNil()) + Expect(*policyReq.Rules[0].Destinations).To(HaveLen(1)) + Expect((*policyReq.Rules[0].Destinations)[0]).To(Equal("test")) + Expect(policyReq.Rules[0].Enabled).To(BeTrue()) + Expect(policyReq.Rules[0].Name).To(Equal("Test TCP")) + Expect(policyReq.Rules[0].Ports).NotTo(BeNil()) + Expect((*policyReq.Rules[0].Ports)).To(HaveLen(1)) + Expect((*policyReq.Rules[0].Ports)[0]).To(Equal("443")) + Expect(policyReq.Rules[0].Protocol).To(BeEquivalentTo(api.PolicyRuleProtocolTcp)) + Expect(policyReq.Rules[0].SourceResource).To(BeNil()) + Expect(policyReq.Rules[0].Sources).NotTo(BeNil()) + Expect(*policyReq.Rules[0].Sources).To(HaveLen(1)) + Expect((*policyReq.Rules[0].Sources)[0]).To(Equal("meow")) + + policyCreated = true + resp := api.Policy{ + Id: &resourceName, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(policyCreated).To(BeTrue()) + }) + }) + + When("TCP information no longer sufficient", func() { + It("should delete tcp policy", func() { + controllerReconciler := &NBPolicyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + ClusterName: "Kubernetes", + } + + nbpolicy.Status.ManagedServiceList = append(nbpolicy.Status.ManagedServiceList, "default/noexist") + nbpolicy.Status.TCPPolicyID = util.Ptr("policyid") + Expect(k8sClient.Status().Update(ctx, nbpolicy)).To(Succeed()) + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + resp := []api.Group{ + { + Id: "meow", + Name: "All", + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + policyDeleted := false + mux.HandleFunc("/api/policies/policyid", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + policyDeleted = true + _, err := w.Write([]byte("{}")) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(policyDeleted).To(BeTrue()) + }) + }) + + When("Enough information to create UDP policy", func() { + It("should create 1 policy", func() { + controllerReconciler := &NBPolicyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + ClusterName: "Kubernetes", + } + + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "meow", + Groups: []string{"test"}, + NetworkID: "test", + Address: "test.default.svc.cluster.local", + PolicyName: resourceName, + UDPPorts: []int32{443}, + }, + } + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + + nbResource.Status = netbirdiov1.NBResourceStatus{ + UDPPorts: []int32{443}, + PolicyName: &resourceName, + Groups: []string{"test"}, + } + Expect(k8sClient.Status().Update(ctx, nbResource)).To(Succeed()) + + nbpolicy.Status.ManagedServiceList = append(nbpolicy.Status.ManagedServiceList, "default/test") + Expect(k8sClient.Status().Update(ctx, nbpolicy)).To(Succeed()) + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + resp := []api.Group{ + { + Id: "meow", + Name: "All", + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + policyCreated := false + mux.HandleFunc("/api/policies", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPost { + var policyReq api.PostApiPoliciesJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + err = json.Unmarshal(bs, &policyReq) + Expect(err).NotTo(HaveOccurred()) + Expect(policyReq.Name).To(Equal("Test UDP")) + Expect(policyReq.Description).To(Or(BeNil(), BeEquivalentTo(util.Ptr("")))) + Expect(policyReq.Enabled).To(BeTrue()) + Expect(policyReq.SourcePostureChecks).To(BeNil()) + Expect(policyReq.Rules).To(HaveLen(1)) + Expect(policyReq.Rules[0].Action).To(BeEquivalentTo(api.PolicyRuleActionAccept)) + Expect(policyReq.Rules[0].Bidirectional).To(BeTrue()) + Expect(policyReq.Rules[0].Description).To(Or(BeNil(), BeEquivalentTo(util.Ptr("")))) + Expect(policyReq.Rules[0].DestinationResource).To(BeNil()) + Expect(policyReq.Rules[0].Destinations).NotTo(BeNil()) + Expect(*policyReq.Rules[0].Destinations).To(HaveLen(1)) + Expect((*policyReq.Rules[0].Destinations)[0]).To(Equal("test")) + Expect(policyReq.Rules[0].Enabled).To(BeTrue()) + Expect(policyReq.Rules[0].Name).To(Equal("Test UDP")) + Expect(policyReq.Rules[0].Ports).NotTo(BeNil()) + Expect((*policyReq.Rules[0].Ports)).To(HaveLen(1)) + Expect((*policyReq.Rules[0].Ports)[0]).To(Equal("443")) + Expect(policyReq.Rules[0].Protocol).To(BeEquivalentTo(api.PolicyRuleProtocolUdp)) + Expect(policyReq.Rules[0].SourceResource).To(BeNil()) + Expect(policyReq.Rules[0].Sources).NotTo(BeNil()) + Expect(*policyReq.Rules[0].Sources).To(HaveLen(1)) + Expect((*policyReq.Rules[0].Sources)[0]).To(Equal("meow")) + + policyCreated = true + resp := api.Policy{ + Id: &resourceName, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(policyCreated).To(BeTrue()) + }) + }) + + When("UDP information no longer sufficient", func() { + It("should delete udp policy", func() { + controllerReconciler := &NBPolicyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + ClusterName: "Kubernetes", + } + + nbpolicy.Status.ManagedServiceList = append(nbpolicy.Status.ManagedServiceList, "default/noexist") + nbpolicy.Status.UDPPolicyID = util.Ptr("policyid") + Expect(k8sClient.Status().Update(ctx, nbpolicy)).To(Succeed()) + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + resp := []api.Group{ + { + Id: "meow", + Name: "All", + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + policyDeleted := false + mux.HandleFunc("/api/policies/policyid", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + policyDeleted = true + _, err := w.Write([]byte("{}")) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(policyDeleted).To(BeTrue()) + }) + }) + + When("Existing protocol gets restricted", func() { + It("Should delete protocol policy", func() { + controllerReconciler := &NBPolicyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + ClusterName: "Kubernetes", + } + + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "meow", + Groups: []string{"test"}, + NetworkID: "test", + Address: "test.default.svc.cluster.local", + PolicyName: resourceName, + TCPPorts: []int32{443}, + }, + } + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + + nbResource.Status = netbirdiov1.NBResourceStatus{ + TCPPorts: []int32{443}, + PolicyName: &resourceName, + Groups: []string{"test"}, + } + Expect(k8sClient.Status().Update(ctx, nbResource)).To(Succeed()) + + nbpolicy.Spec.Protocols = []string{"udp"} + Expect(k8sClient.Update(ctx, nbpolicy)).To(Succeed()) + + nbpolicy.Status.ManagedServiceList = append(nbpolicy.Status.ManagedServiceList, "default/test") + nbpolicy.Status.TCPPolicyID = util.Ptr("policyid") + Expect(k8sClient.Status().Update(ctx, nbpolicy)).To(Succeed()) + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + resp := []api.Group{ + { + Id: "meow", + Name: "All", + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + policyDeleted := false + mux.HandleFunc("/api/policies/policyid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodDelete { + policyDeleted = true + _, err := w.Write([]byte("{}")) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(policyDeleted).To(BeTrue()) + }) + }) + + When("Updating existing policy", func() { + AfterEach(func() { + nbresource := &netbirdiov1.NBResource{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "test-b"}, nbresource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance NBResource") + Expect(k8sClient.Delete(ctx, nbresource)).To(Succeed()) + } + }) + + It("Should give all information to Update method", func() { + controllerReconciler := &NBPolicyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + ClusterName: "Kubernetes", + } + + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "meow", + Groups: []string{"test"}, + NetworkID: "test", + Address: "test.default.svc.cluster.local", + PolicyName: resourceName, + TCPPorts: []int32{443}, + }, + } + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + + nbResource.Status = netbirdiov1.NBResourceStatus{ + TCPPorts: []int32{443}, + PolicyName: &resourceName, + Groups: []string{"test"}, + } + Expect(k8sClient.Status().Update(ctx, nbResource)).To(Succeed()) + + nbResourceB := &netbirdiov1.NBResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-b", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "meow-b", + Groups: []string{"test-b"}, + NetworkID: "test", + Address: "test-b.default.svc.cluster.local", + PolicyName: resourceName, + TCPPorts: []int32{80}, + }, + } + Expect(k8sClient.Create(ctx, nbResourceB)).To(Succeed()) + + nbResourceB.Status = netbirdiov1.NBResourceStatus{ + TCPPorts: []int32{80}, + PolicyName: &resourceName, + Groups: []string{"test-b"}, + } + Expect(k8sClient.Status().Update(ctx, nbResourceB)).To(Succeed()) + + nbpolicy.Status.ManagedServiceList = append(nbpolicy.Status.ManagedServiceList, "default/test", "default/test-b") + nbpolicy.Status.TCPPolicyID = util.Ptr("policyid") + Expect(k8sClient.Status().Update(ctx, nbpolicy)).To(Succeed()) + + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + resp := []api.Group{ + { + Id: "meow", + Name: "All", + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + policyUpdated := false + mux.HandleFunc("/api/policies/policyid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPut { + policyUpdated = true + + var policyReq api.PostApiPoliciesJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + err = json.Unmarshal(bs, &policyReq) + Expect(err).NotTo(HaveOccurred()) + Expect(policyReq.Name).To(Equal("Test TCP")) + Expect(policyReq.Description).To(Or(BeNil(), BeEquivalentTo(util.Ptr("")))) + Expect(policyReq.Enabled).To(BeTrue()) + Expect(policyReq.SourcePostureChecks).To(BeNil()) + Expect(policyReq.Rules).To(HaveLen(1)) + Expect(policyReq.Rules[0].Action).To(BeEquivalentTo(api.PolicyRuleActionAccept)) + Expect(policyReq.Rules[0].Bidirectional).To(BeTrue()) + Expect(policyReq.Rules[0].Description).To(Or(BeNil(), BeEquivalentTo(util.Ptr("")))) + Expect(policyReq.Rules[0].DestinationResource).To(BeNil()) + Expect(policyReq.Rules[0].Destinations).NotTo(BeNil()) + Expect(*policyReq.Rules[0].Destinations).To(HaveLen(2)) + Expect((*policyReq.Rules[0].Destinations)).To(ConsistOf([]string{"test", "test-b"})) + Expect(policyReq.Rules[0].Enabled).To(BeTrue()) + Expect(policyReq.Rules[0].Name).To(Equal("Test TCP")) + Expect(policyReq.Rules[0].Ports).NotTo(BeNil()) + Expect((*policyReq.Rules[0].Ports)).To(HaveLen(2)) + Expect((*policyReq.Rules[0].Ports)).To(ConsistOf([]string{"443", "80"})) + Expect(policyReq.Rules[0].Protocol).To(BeEquivalentTo(api.PolicyRuleProtocolTcp)) + Expect(policyReq.Rules[0].SourceResource).To(BeNil()) + Expect(policyReq.Rules[0].Sources).NotTo(BeNil()) + Expect(*policyReq.Rules[0].Sources).To(HaveLen(1)) + Expect((*policyReq.Rules[0].Sources)[0]).To(Equal("meow")) + + _, err = w.Write([]byte("{}")) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(policyUpdated).To(BeTrue()) + }) + }) + + When("NBPolicy is set for deletion", func() { + It("should delete Policies", func() { + controllerReconciler := &NBPolicyReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + ClusterName: "Kubernetes", + } + + nbpolicy.Status.TCPPolicyID = util.Ptr("policyidtcp") + nbpolicy.Status.UDPPolicyID = util.Ptr("policyidudp") + Expect(k8sClient.Status().Update(ctx, nbpolicy)).To(Succeed()) + + Expect(k8sClient.Delete(ctx, nbpolicy)).To(Succeed()) + + tcpPolicyDeleted := false + mux.HandleFunc("/api/policies/policyidtcp", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + tcpPolicyDeleted = true + _, err := w.Write([]byte("{}")) + Expect(err).NotTo(HaveOccurred()) + } + }) + + udpPolicyDeleted := false + mux.HandleFunc("/api/policies/policyidudp", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + udpPolicyDeleted = true + _, err := w.Write([]byte("{}")) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(tcpPolicyDeleted).To(BeTrue()) + Expect(udpPolicyDeleted).To(BeTrue()) + + err = k8sClient.Get(ctx, typeNamespacedName, nbpolicy) + Expect(errors.IsNotFound(err)).To(BeTrue()) }) - Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. }) }) }) diff --git a/internal/controller/nbresource_controller.go b/internal/controller/nbresource_controller.go index 666febe..2e42000 100644 --- a/internal/controller/nbresource_controller.go +++ b/internal/controller/nbresource_controller.go @@ -3,6 +3,7 @@ package controller import ( "context" "fmt" + "slices" "strings" "time" @@ -48,6 +49,9 @@ func (r *NBResourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) originalResource := nbResource.DeepCopy() defer func() { + if originalResource.DeletionTimestamp != nil && len(nbResource.Finalizers) == 0 { + return + } if !originalResource.Status.Equal(nbResource.Status) { updateErr := r.Client.Status().Update(ctx, nbResource) if updateErr != nil { @@ -110,8 +114,8 @@ func (r *NBResourceReconciler) handlePolicy(ctx context.Context, req ctrl.Reques var nbPolicy netbirdiov1.NBPolicy if nbResource.Spec.PolicyName == "" && nbResource.Status.PolicyName != nil { // Remove self reference from policy status - nbResource.Status.PolicyName = nil err := r.Client.Get(ctx, types.NamespacedName{Name: *nbResource.Status.PolicyName}, &nbPolicy) + nbResource.Status.PolicyName = nil if err != nil { logger.Error(errKubernetesAPI, "error getting NBPolicy", "err", err, "policyName", nbResource.Spec.PolicyName) return err @@ -123,7 +127,24 @@ func (r *NBResourceReconciler) handlePolicy(ctx context.Context, req ctrl.Reques } } else { // Update policy settings if any difference is found - // TODO: Handle updated policy name by removing reference from old policy name in status.policyName + if nbResource.Status.PolicyName != nil { + err := r.Client.Get(ctx, types.NamespacedName{Name: *nbResource.Status.PolicyName}, &nbPolicy) + if !errors.IsNotFound(err) { + if err != nil { + logger.Error(errKubernetesAPI, "error getting NBPolicy", "err", err, "policyName", nbResource.Spec.PolicyName) + return err + } + + if util.Contains(nbPolicy.Status.ManagedServiceList, req.NamespacedName.String()) { + nbPolicy.Status.ManagedServiceList = util.Without(nbPolicy.Status.ManagedServiceList, req.NamespacedName.String()) + err := r.Client.Status().Update(ctx, &nbPolicy) + if err != nil { + logger.Error(errKubernetesAPI, "error updating NBPolicy", "err", err, "policyName", nbResource.Spec.PolicyName) + return err + } + } + } + } err := r.Client.Get(ctx, types.NamespacedName{Name: nbResource.Spec.PolicyName}, &nbPolicy) if err != nil { logger.Error(errKubernetesAPI, "error getting NBPolicy", "err", err, "policyName", nbResource.Spec.PolicyName) @@ -230,8 +251,6 @@ func (r *NBResourceReconciler) handleNetBirdResource(ctx context.Context, nbReso return nil, err } - nbResource.Status.NetworkResourceID = &resource.Id - } else if nbResource.Status.NetworkResourceID == nil && resource != nil { nbResource.Status.NetworkResourceID = &resource.Id } else if resource == nil { // Status remembers networkResourceID but resource was deleted elsewhere @@ -265,6 +284,44 @@ func (r *NBResourceReconciler) handleNetBirdResource(ctx context.Context, nbReso // handleGroups create NBGroup objects for each group specified in NBResource func (r *NBResourceReconciler) handleGroups(ctx context.Context, req ctrl.Request, nbResource *netbirdiov1.NBResource, logger logr.Logger) ([]string, *ctrl.Result, error) { + nbGroupList := netbirdiov1.NBGroupList{} + err := r.Client.List(ctx, &nbGroupList, &client.ListOptions{Namespace: req.Namespace}) + if err != nil { + logger.Error(errKubernetesAPI, "error listing NBGroup", "err", err) + return nil, nil, err + } + + for _, g := range nbGroupList.Items { + ownerIndex := -1 + for idx, o := range g.OwnerReferences { + if o.UID == nbResource.UID { + ownerIndex = idx + break + } + } + if ownerIndex == -1 { + continue + } + if util.Contains(nbResource.Spec.Groups, g.Spec.Name) { + continue + } + if len(g.OwnerReferences) > 1 { + g.OwnerReferences = slices.Delete(g.OwnerReferences, ownerIndex, ownerIndex+1) + err = r.Client.Update(ctx, &g) + if err != nil && !errors.IsNotFound(err) { + logger.Error(errKubernetesAPI, "error updating NBGroup", "err", err) + return nil, nil, err + } + } else if len(g.OwnerReferences) == 1 { + g.Finalizers = util.Without(g.Finalizers, "netbird.io/resource-cleanup") + err = r.Client.Update(ctx, &g) + if err != nil && !errors.IsNotFound(err) { + logger.Error(errKubernetesAPI, "error updating NBGroup", "err", err) + return nil, nil, err + } + } + } + var groupIDs []string for _, groupName := range nbResource.Spec.Groups { @@ -283,8 +340,8 @@ func (r *NBResourceReconciler) handleGroups(ctx context.Context, req ctrl.Reques Namespace: nbResource.Namespace, OwnerReferences: []v1.OwnerReference{ { - APIVersion: nbResource.APIVersion, - Kind: nbResource.Kind, + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBResource", Name: nbResource.Name, UID: nbResource.UID, BlockOwnerDeletion: util.Ptr(true), @@ -315,8 +372,8 @@ func (r *NBResourceReconciler) handleGroups(ctx context.Context, req ctrl.Reques if !ownerExists { nbGroup.OwnerReferences = append(nbGroup.OwnerReferences, v1.OwnerReference{ - APIVersion: nbResource.APIVersion, - Kind: nbResource.Kind, + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBResource", Name: nbResource.Name, UID: nbResource.UID, BlockOwnerDeletion: util.Ptr(true), @@ -380,8 +437,24 @@ func (r *NBResourceReconciler) handleDelete(ctx context.Context, req ctrl.Reques } for _, g := range nbGroupList.Items { - // TODO: Handle multiple owners - if len(g.OwnerReferences) > 0 && g.OwnerReferences[0].UID == nbResource.UID { + ownerIndex := -1 + for idx, o := range g.OwnerReferences { + if o.UID == nbResource.UID { + ownerIndex = idx + break + } + } + if ownerIndex == -1 { + continue + } + if len(g.OwnerReferences) > 1 { + g.OwnerReferences = slices.Delete(g.OwnerReferences, ownerIndex, ownerIndex+1) + err = r.Client.Update(ctx, &g) + if err != nil && !errors.IsNotFound(err) { + logger.Error(errKubernetesAPI, "error updating NBGroup", "err", err) + return err + } + } else if len(g.OwnerReferences) == 1 { g.Finalizers = util.Without(g.Finalizers, "netbird.io/resource-cleanup") err = r.Client.Update(ctx, &g) if err != nil && !errors.IsNotFound(err) { diff --git a/internal/controller/nbresource_controller_test.go b/internal/controller/nbresource_controller_test.go index 4ddcd4d..8e265b6 100644 --- a/internal/controller/nbresource_controller_test.go +++ b/internal/controller/nbresource_controller_test.go @@ -2,16 +2,25 @@ package controller import ( "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "github.com/go-logr/logr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/reconcile" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" + "github.com/netbirdio/kubernetes-operator/internal/util" + netbird "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" ) var _ = Describe("NBResource Controller", func() { @@ -22,48 +31,676 @@ var _ = Describe("NBResource Controller", func() { typeNamespacedName := types.NamespacedName{ Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed + Namespace: "default", } nbresource := &netbirdiov1.NBResource{} + var netbirdClient *netbird.Client + var mux *http.ServeMux + var server *httptest.Server + var controllerReconciler *NBResourceReconciler BeforeEach(func() { - Skip("Not implemented yet") + ctrl.SetLogger(logr.New(GinkgoLogr.GetSink())) + mux = &http.ServeMux{} + server = httptest.NewServer(mux) + netbirdClient = netbird.New(server.URL, "ABC") + controllerReconciler = &NBResourceReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + } + By("creating the custom resource for the Kind NBResource") err := k8sClient.Get(ctx, typeNamespacedName, nbresource) if err != nil && errors.IsNotFound(err) { - resource := &netbirdiov1.NBResource{ + nbresource = &netbirdiov1.NBResource{ ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", + Name: resourceName, + Namespace: "default", + Finalizers: []string{"netbird.io/cleanup"}, + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "Test", + NetworkID: "test", + Address: "test.default.svc.cluster.local", + Groups: []string{"meow"}, + TCPPorts: []int32{80}, }, - // TODO(user): Specify other spec details if needed. } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + Expect(k8sClient.Create(ctx, nbresource)).To(Succeed()) } }) AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. resource := &netbirdiov1.NBResource{} err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) - By("Cleanup the specific resource instance NBResource") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - }) - It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") - controllerReconciler := &NBResourceReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + if len(resource.Finalizers) > 0 { + resource.Finalizers = nil + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + } + + By("Cleanup the specific resource instance NBResource") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) } + }) - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, + BeforeEach(func() { + mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + resp := []api.Group{ + { + Id: "test", + Name: "meow", + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + When("Network Resource doesn't exist", Ordered, func() { + AfterAll(func() { + nbGroup := &netbirdiov1.NBGroup{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meow"}, nbGroup) + if !errors.IsNotFound(err) { + if len(nbGroup.Finalizers) > 0 { + nbGroup.Finalizers = nil + Expect(k8sClient.Update(ctx, nbGroup)).To(Succeed()) + } + Expect(k8sClient.Delete(ctx, nbGroup)).To(Succeed()) + } + }) + + It("should create NBGroups", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + nbGroup := &netbirdiov1.NBGroup{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meow"}, nbGroup)).To(Succeed()) + nbGroup.Status.GroupID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, nbGroup)).To(Succeed()) + }) + + It("should create Network Resource", func() { + networkResourceCreated := false + + mux.HandleFunc("/api/networks/test/resources", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPost { + networkResourceCreated = true + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + var req api.PostApiNetworksNetworkIdResourcesJSONRequestBody + Expect(json.Unmarshal(bs, &req)).To(Succeed()) + + Expect(req.Name).To(Equal("Test")) + Expect(req.Description).NotTo(BeNil()) + Expect(*req.Description).To(BeEquivalentTo("Created by kubernetes-operator")) + Expect(req.Enabled).To(BeTrue()) + Expect(req.Groups).To(ConsistOf([]string{"test"})) + Expect(req.Address).To(Equal(nbresource.Spec.Address)) + + resp := api.NetworkResource{ + Address: req.Address, + Description: req.Description, + Enabled: req.Enabled, + Groups: []api.GroupMinimum{ + { + Id: "test", + Name: "meow", + }, + }, + Id: "test", + Name: req.Name, + Type: api.NetworkResourceTypeDomain, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(networkResourceCreated).To(BeTrue()) + }) + }) + When("Network Resource exists", func() { + BeforeEach(func() { + nbresource.Status.NetworkResourceID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, nbresource)).To(Succeed()) + + nbGroup := &netbirdiov1.NBGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "meow", + Namespace: "default", + Finalizers: []string{"netbird.io/resource-cleanup"}, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBResource", + Name: "test-resource", + UID: nbresource.UID, + }, + }, + }, + Spec: netbirdiov1.NBGroupSpec{ + Name: "meow", + }, + } + Expect(k8sClient.Create(ctx, nbGroup)).To(Succeed()) + + nbGroup.Status.GroupID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, nbGroup)).To(Succeed()) + }) + + AfterEach(func() { + nbGroup := &netbirdiov1.NBGroup{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meow"}, nbGroup) + if !errors.IsNotFound(err) { + if len(nbGroup.Finalizers) > 0 { + nbGroup.Finalizers = nil + Expect(k8sClient.Update(ctx, nbGroup)).To(Succeed()) + } + Expect(k8sClient.Delete(ctx, nbGroup)).To(Succeed()) + } + }) + When("Network Resource is out of date", func() { + It("should update Network Resource", func() { + resourceUpdated := false + mux.HandleFunc("/api/networks/test/resources/test", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + resp := api.NetworkResource{ + Address: nbresource.Spec.Address, + Description: &networkDescription, + Enabled: false, + Groups: []api.GroupMinimum{ + { + Id: "test", + Name: "meow", + }, + { + Id: "test2", + Name: "meow2", + }, + }, + Id: "test", + Name: nbresource.Spec.Name, + Type: api.NetworkResourceTypeDomain, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } else if r.Method == http.MethodPut { + resourceUpdated = true + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + var req api.PutApiNetworksNetworkIdResourcesResourceIdJSONRequestBody + Expect(json.Unmarshal(bs, &req)).To(Succeed()) + + Expect(req.Name).To(Equal("Test")) + Expect(req.Description).NotTo(BeNil()) + Expect(*req.Description).To(BeEquivalentTo("Created by kubernetes-operator")) + Expect(req.Enabled).To(BeTrue()) + Expect(req.Groups).To(ConsistOf([]string{"test"})) + Expect(req.Address).To(Equal(nbresource.Spec.Address)) + + resp := api.NetworkResource{ + Address: nbresource.Spec.Address, + Description: &networkDescription, + Enabled: true, + Groups: []api.GroupMinimum{ + { + Id: "test", + Name: "meow", + }, + }, + Id: "test", + Name: nbresource.Spec.Name, + Type: api.NetworkResourceTypeDomain, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + nbresource.Status.NetworkResourceID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, nbresource)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resourceUpdated).To(BeTrue()) + }) + }) + + When("Network Resource is up-to-date", func() { + BeforeEach(func() { + mux.HandleFunc("/api/networks/test/resources/test", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + resp := api.NetworkResource{ + Address: nbresource.Spec.Address, + Description: &networkDescription, + Enabled: true, + Groups: []api.GroupMinimum{ + { + Id: "test", + Name: "meow", + }, + }, + Id: "test", + Name: nbresource.Spec.Name, + Type: api.NetworkResourceTypeDomain, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + }) + + When("Policy is specified", Ordered, func() { + BeforeAll(func() { + nbPolicy := &netbirdiov1.NBPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-a", + }, + Spec: netbirdiov1.NBPolicySpec{ + Name: "Test A", + SourceGroups: []string{"All"}, + }, + } + Expect(k8sClient.Create(ctx, nbPolicy)).To(Succeed()) + + nbPolicy = &netbirdiov1.NBPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-b", + }, + Spec: netbirdiov1.NBPolicySpec{ + Name: "Test B", + SourceGroups: []string{"All"}, + }, + } + Expect(k8sClient.Create(ctx, nbPolicy)).To(Succeed()) + }) + + AfterAll(func() { + nbPolicy := &netbirdiov1.NBPolicy{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: "test-a"}, nbPolicy) + if !errors.IsNotFound(err) { + Expect(k8sClient.Delete(ctx, nbPolicy)).To(Succeed()) + } + + nbPolicy = &netbirdiov1.NBPolicy{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: "test-b"}, nbPolicy) + if !errors.IsNotFound(err) { + Expect(k8sClient.Delete(ctx, nbPolicy)).To(Succeed()) + } + }) + It("should update policy status", func() { + nbresource.Spec.PolicyName = "test-a" + Expect(k8sClient.Update(ctx, nbresource)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbPolicy := &netbirdiov1.NBPolicy{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-a"}, nbPolicy)).To(Succeed()) + Expect(nbPolicy.Status.ManagedServiceList).To(ContainElement("default/test-resource")) + }) + + When("Policy is updated", func() { + It("should remove old reference and add new reference", func() { + nbresource.Spec.PolicyName = "test-b" + Expect(k8sClient.Update(ctx, nbresource)).To(Succeed()) + + nbresource.Status.PolicyName = util.Ptr("test-a") + Expect(k8sClient.Status().Update(ctx, nbresource)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbPolicy := &netbirdiov1.NBPolicy{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-a"}, nbPolicy)).To(Succeed()) + Expect(nbPolicy.Status.ManagedServiceList).NotTo(ContainElement("default/test-resource")) + + nbPolicy = &netbirdiov1.NBPolicy{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-b"}, nbPolicy)).To(Succeed()) + Expect(nbPolicy.Status.ManagedServiceList).To(ContainElement("default/test-resource")) + }) + }) + + When("Policy is removed", func() { + It("should remove old reference", func() { + nbPolicy := &netbirdiov1.NBPolicy{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-a"}, nbPolicy)).To(Succeed()) + nbPolicy.Status.ManagedServiceList = []string{"default/test-resource"} + Expect(k8sClient.Status().Update(ctx, nbPolicy)).To(Succeed()) + + nbresource.Spec.PolicyName = "" + Expect(k8sClient.Update(ctx, nbresource)).To(Succeed()) + + nbresource.Status.PolicyName = util.Ptr("test-a") + Expect(k8sClient.Status().Update(ctx, nbresource)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbPolicy = &netbirdiov1.NBPolicy{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test-a"}, nbPolicy)).To(Succeed()) + Expect(nbPolicy.Status.ManagedServiceList).NotTo(ContainElement("default/test-resource")) + }) + }) + }) + + When("Groups are changed", func() { + When("Removed groups are no longer referenced by anything", func() { + It("should only remove finalizer", func() { + nbresource.Spec.Groups = []string{"meow2"} + Expect(k8sClient.Update(ctx, nbresource)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbGroup := &netbirdiov1.NBGroup{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meow"}, nbGroup)).To(Succeed()) + Expect(nbGroup.Finalizers).To(BeEmpty()) + Expect(nbGroup.OwnerReferences).To(HaveLen(1)) + }) + }) + + When("Removed groups are referenced by something else", func() { + BeforeEach(func() { + otherResource := &netbirdiov1.NBResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-test", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "nottest", + NetworkID: "test", + Address: "test", + Groups: []string{"test"}, + }, + } + Expect(k8sClient.Create(ctx, otherResource)).To(Succeed()) + + nbGroup := &netbirdiov1.NBGroup{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meow"}, nbGroup)).To(Succeed()) + nbGroup.OwnerReferences = append(nbGroup.OwnerReferences, metav1.OwnerReference{ + APIVersion: "netbird.io/v1", + Kind: "NBResource", + Name: "not-test", + UID: otherResource.UID, + }) + Expect(k8sClient.Update(ctx, nbGroup)).To(Succeed()) + }) + + AfterEach(func() { + otherResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "not-test"}, otherResource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, otherResource)).To(Succeed()) + }) + + It("should only remove owner reference", func() { + nbresource.Spec.Groups = []string{"meow2"} + Expect(k8sClient.Update(ctx, nbresource)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbGroup := &netbirdiov1.NBGroup{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meow"}, nbGroup)).To(Succeed()) + Expect(nbGroup.Finalizers).To(HaveLen(1)) + Expect(nbGroup.OwnerReferences).To(HaveLen(1)) + Expect(nbGroup.OwnerReferences[0].Name).To(Equal("not-test")) + }) + }) + When("New groups are added", func() { + It("should create new groups", func() { + nbresource.Spec.Groups = []string{"meow", "meow3"} + Expect(k8sClient.Update(ctx, nbresource)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbGroup := &netbirdiov1.NBGroup{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meow3"}, nbGroup)).To(Succeed()) + Expect(nbGroup.OwnerReferences).To(HaveLen(1)) + Expect(nbGroup.Finalizers).To(ConsistOf([]string{"netbird.io/group-cleanup", "netbird.io/resource-cleanup"})) + }) + }) + }) + }) + + When("Network Resource is removed from NetBird", func() { + BeforeEach(func() { + mux.HandleFunc("/api/networks/test/resources/test", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + w.WriteHeader(404) + _, err := w.Write([]byte(`{"message": "not found", "code": 404}`)) + Expect(err).NotTo(HaveOccurred()) + } + }) + }) + + It("should remove network resource ID and requeue", func() { + res, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Requeue).To(BeTrue()) + + Expect(k8sClient.Get(ctx, typeNamespacedName, nbresource)).To(Succeed()) + Expect(nbresource.Status.NetworkResourceID).To(BeNil()) + }) + }) + }) + When("NBResource is set for deletion", Ordered, func() { + BeforeAll(func() { + nbresource.Spec.Groups = []string{"meow", "meowdelete"} + Expect(k8sClient.Update(ctx, nbresource)).To(Succeed()) + nbresource.Status.Groups = []string{"test", "testdelete"} + nbresource.Status.PolicyName = util.Ptr("test") + nbresource.Status.NetworkResourceID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, nbresource)).To(Succeed()) + + nbPolicy := &netbirdiov1.NBPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: netbirdiov1.NBPolicySpec{ + Name: "Test", + SourceGroups: []string{"All"}, + Bidirectional: true, + }, + } + Expect(k8sClient.Create(ctx, nbPolicy)).To(Succeed()) + + nbPolicy.Status.ManagedServiceList = []string{"default/test-resource"} + Expect(k8sClient.Status().Update(ctx, nbPolicy)).To(Succeed()) + + nbGroup := &netbirdiov1.NBGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "meow", + Namespace: "default", + Finalizers: []string{"netbird.io/resource-cleanup"}, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBResource", + Name: nbresource.Name, + UID: nbresource.UID, + }, + }, + }, + Spec: netbirdiov1.NBGroupSpec{ + Name: "meow", + }, + } + Expect(k8sClient.Create(ctx, nbGroup)).To(Succeed()) + + othernbresource := &netbirdiov1.NBResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-resource", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "test", + NetworkID: "test", + Address: "test", + Groups: []string{"test"}, + }, + } + Expect(k8sClient.Create(ctx, othernbresource)).To(Succeed()) + + nbGroup = &netbirdiov1.NBGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "meowdelete", + Namespace: "default", + Finalizers: []string{"netbird.io/resource-cleanup"}, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBResource", + Name: nbresource.Name, + UID: nbresource.UID, + }, + { + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBResource", + Name: othernbresource.Name, + UID: othernbresource.UID, + }, + }, + }, + Spec: netbirdiov1.NBGroupSpec{ + Name: "meow", + }, + } + Expect(k8sClient.Create(ctx, nbGroup)).To(Succeed()) + }) + AfterAll(func() { + policy := &netbirdiov1.NBPolicy{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "test"}, policy) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + if len(policy.Finalizers) > 0 { + policy.Finalizers = nil + Expect(k8sClient.Update(ctx, policy)).To(Succeed()) + } + + Expect(k8sClient.Delete(ctx, policy)).To(Succeed()) + } + + resource := &netbirdiov1.NBResource{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "other-resource"}, resource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + if len(resource.Finalizers) > 0 { + resource.Finalizers = nil + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + } + + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + + group := &netbirdiov1.NBGroup{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meow"}, group) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + if len(group.Finalizers) > 0 { + group.Finalizers = nil + Expect(k8sClient.Update(ctx, group)).To(Succeed()) + } + + Expect(k8sClient.Delete(ctx, group)).To(Succeed()) + } + + group = &netbirdiov1.NBGroup{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meowdelete"}, group) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + if len(group.Finalizers) > 0 { + group.Finalizers = nil + Expect(k8sClient.Update(ctx, group)).To(Succeed()) + } + + Expect(k8sClient.Delete(ctx, group)).To(Succeed()) + } + }) + It("should delete Network Resource", func() { + Expect(k8sClient.Delete(ctx, nbresource)).To(Succeed()) + resourceDeleted := false + mux.HandleFunc("/api/networks/test/resources/test", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodDelete { + resourceDeleted = true + _, err := w.Write([]byte(`{}`)) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resourceDeleted).To(BeTrue()) + + err = k8sClient.Get(ctx, typeNamespacedName, nbresource) + Expect(errors.IsNotFound(err)).To(BeTrue()) + }) + It("should remove resource cleanup finalizer from solely-owned NBGroups", func() { + group := &netbirdiov1.NBGroup{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meow"}, group) + if errors.IsNotFound(err) { + return + } + Expect(group.Finalizers).To(BeEmpty()) + }) + It("should remove owner reference from shared NBGroups", func() { + group := &netbirdiov1.NBGroup{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "meowdelete"}, group)).To(Succeed()) + Expect(group.Finalizers).To(HaveLen(1)) + Expect(group.OwnerReferences).To(HaveLen(1)) + }) + It("should remove policy reference", func() { + policy := &netbirdiov1.NBPolicy{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "test"}, policy)).To(Succeed()) + Expect(policy.Status.ManagedServiceList).NotTo(ContainElement("default/test-resource")) }) - Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. }) }) }) diff --git a/internal/controller/nbroutingpeer_controller.go b/internal/controller/nbroutingpeer_controller.go index eaac7ae..55e96c5 100644 --- a/internal/controller/nbroutingpeer_controller.go +++ b/internal/controller/nbroutingpeer_controller.go @@ -51,10 +51,10 @@ func (r *NBRoutingPeerReconciler) Reconcile(ctx context.Context, req ctrl.Reques originalNBRP := nbrp.DeepCopy() defer func() { - if originalNBRP.Status.NetworkID != nbrp.Status.NetworkID || - originalNBRP.Status.RouterID != nbrp.Status.RouterID || - originalNBRP.Status.SetupKeyID != nbrp.Status.SetupKeyID || - !util.Equivalent(originalNBRP.Status.Conditions, nbrp.Status.Conditions) { + if originalNBRP.DeletionTimestamp != nil && len(nbrp.Finalizers) == 0 { + return + } + if !originalNBRP.Status.Equal(nbrp.Status) { err = r.Client.Status().Update(ctx, nbrp) if err != nil { logger.Error(errKubernetesAPI, "error updating NBRoutingPeer Status", "err", err) @@ -128,8 +128,8 @@ func (r *NBRoutingPeerReconciler) handleDeployment(ctx context.Context, req ctrl Namespace: nbrp.Namespace, OwnerReferences: []v1.OwnerReference{ { - APIVersion: nbrp.APIVersion, - Kind: nbrp.Kind, + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBRoutingPeer", Name: nbrp.Name, UID: nbrp.UID, BlockOwnerDeletion: util.Ptr(true), @@ -199,8 +199,8 @@ func (r *NBRoutingPeerReconciler) handleDeployment(ctx context.Context, req ctrl updatedDeployment.ObjectMeta.Namespace = nbrp.Namespace updatedDeployment.ObjectMeta.OwnerReferences = []v1.OwnerReference{ { - APIVersion: nbrp.APIVersion, - Kind: nbrp.Kind, + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBRoutingPeer", Name: nbrp.Name, UID: nbrp.UID, BlockOwnerDeletion: util.Ptr(true), @@ -355,8 +355,8 @@ func (r *NBRoutingPeerReconciler) handleSetupKey(ctx context.Context, req ctrl.R Namespace: nbrp.Namespace, OwnerReferences: []v1.OwnerReference{ { - APIVersion: nbrp.APIVersion, - Kind: nbrp.Kind, + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBRoutingPeer", Name: nbrp.Name, UID: nbrp.UID, BlockOwnerDeletion: util.Ptr(true), @@ -387,8 +387,17 @@ func (r *NBRoutingPeerReconciler) handleSetupKey(ctx context.Context, req ctrl.R } if (err != nil && strings.Contains(err.Error(), "not found")) || setupKey.Revoked { - nbrp.Status.SetupKeyID = nil + if setupKey != nil && setupKey.Revoked { + err = r.netbird.SetupKeys.Delete(ctx, *nbrp.Status.SetupKeyID) + if err != nil { + logger.Error(errNetBirdAPI, "error deleting setup key", "err", err) + nbrp.Status.Conditions = netbirdiov1.NBConditionFalse("APIError", fmt.Sprintf("error deleting setup key: %v", err)) + return &ctrl.Result{}, err + } + } + + nbrp.Status.SetupKeyID = nil // Requeue to avoid repeating code return &ctrl.Result{Requeue: true}, nil } @@ -447,8 +456,8 @@ func (r *NBRoutingPeerReconciler) handleGroup(ctx context.Context, req ctrl.Requ Namespace: nbrp.Namespace, OwnerReferences: []v1.OwnerReference{ { - APIVersion: nbrp.APIVersion, - Kind: nbrp.Kind, + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBRoutingPeer", Name: nbrp.Name, UID: nbrp.UID, BlockOwnerDeletion: util.Ptr(true), diff --git a/internal/controller/nbroutingpeer_controller_test.go b/internal/controller/nbroutingpeer_controller_test.go index 0b6b993..af03663 100644 --- a/internal/controller/nbroutingpeer_controller_test.go +++ b/internal/controller/nbroutingpeer_controller_test.go @@ -2,16 +2,27 @@ package controller import ( "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "github.com/go-logr/logr" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/reconcile" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" + "github.com/netbirdio/kubernetes-operator/internal/util" + netbird "github.com/netbirdio/netbird/management/client/rest" + "github.com/netbirdio/netbird/management/server/http/api" ) var _ = Describe("NBRoutingPeer Controller", func() { @@ -22,49 +33,980 @@ var _ = Describe("NBRoutingPeer Controller", func() { typeNamespacedName := types.NamespacedName{ Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed + Namespace: "default", } nbroutingpeer := &netbirdiov1.NBRoutingPeer{} + var netbirdClient *netbird.Client + var mux *http.ServeMux + var server *httptest.Server + var controllerReconciler *NBRoutingPeerReconciler BeforeEach(func() { - Skip("Not implemented yet") + ctrl.SetLogger(logr.New(GinkgoLogr.GetSink())) + mux = &http.ServeMux{} + server = httptest.NewServer(mux) + netbirdClient = netbird.New(server.URL, "ABC") + controllerReconciler = &NBRoutingPeerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + netbird: netbirdClient, + ClientImage: "netbirdio/netbird:latest", + ClusterName: "kubernetes", + NamespacedNetworks: false, + } + By("creating the custom resource for the Kind NBRoutingPeer") err := k8sClient.Get(ctx, typeNamespacedName, nbroutingpeer) if err != nil && errors.IsNotFound(err) { - resource := &netbirdiov1.NBRoutingPeer{ + nbroutingpeer = &netbirdiov1.NBRoutingPeer{ ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", + Name: resourceName, + Namespace: "default", + Finalizers: []string{"netbird.io/cleanup"}, + }, + Spec: netbirdiov1.NBRoutingPeerSpec{ + Replicas: util.Ptr(int32(0)), }, - // TODO(user): Specify other spec details if needed. } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + Expect(k8sClient.Create(ctx, nbroutingpeer)).To(Succeed()) } }) AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. resource := &netbirdiov1.NBRoutingPeer{} err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) - By("Cleanup the specific resource instance NBRoutingPeer") - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - }) - It("should successfully reconcile the resource", func() { - Skip("Not implemented yet") - By("Reconciling the created resource") - controllerReconciler := &NBRoutingPeerReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), + if len(resource.Finalizers) > 0 { + resource.Finalizers = nil + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + } + + err = k8sClient.Delete(ctx, resource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } } - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, + group := &netbirdiov1.NBGroup{} + err = k8sClient.Get(ctx, typeNamespacedName, group) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + if len(group.Finalizers) > 0 { + group.Finalizers = nil + Expect(k8sClient.Update(ctx, group)).To(Succeed()) + } + + err = k8sClient.Delete(ctx, group) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + + deploy := &appsv1.Deployment{} + err = k8sClient.Get(ctx, typeNamespacedName, deploy) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + if len(deploy.Finalizers) > 0 { + deploy.Finalizers = nil + Expect(k8sClient.Update(ctx, deploy)).To(Succeed()) + } + + err = k8sClient.Delete(ctx, deploy) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + + secret := &corev1.Secret{} + err = k8sClient.Get(ctx, typeNamespacedName, secret) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + if len(secret.Finalizers) > 0 { + secret.Finalizers = nil + Expect(k8sClient.Update(ctx, secret)).To(Succeed()) + } + + err = k8sClient.Delete(ctx, secret) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + + nbresource := &netbirdiov1.NBResource{} + err = k8sClient.Get(ctx, typeNamespacedName, nbresource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + + if len(nbresource.Finalizers) > 0 { + nbresource.Finalizers = nil + Expect(k8sClient.Update(ctx, nbresource)).To(Succeed()) + } + + err = k8sClient.Delete(ctx, nbresource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + + When("Network doesn't exist", func() { + BeforeEach(func() { + group := &netbirdiov1.NBGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: typeNamespacedName.Name, + Namespace: typeNamespacedName.Namespace, + }, + Spec: netbirdiov1.NBGroupSpec{ + Name: controllerReconciler.ClusterName, + }, + } + Expect(k8sClient.Create(ctx, group)).To(Succeed()) + }) + It("should create network", func() { + networkCreated := false + mux.HandleFunc("/api/networks", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPost { + networkCreated = true + var req api.PostApiNetworksJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(json.Unmarshal(bs, &req)).To(Succeed()) + Expect(req.Name).To(Equal(controllerReconciler.ClusterName)) + Expect(req.Description).NotTo(BeNil()) + Expect(req.Description).To(BeEquivalentTo(&networkDescription)) + resp := api.Network{ + Id: "test", + Description: req.Description, + Name: req.Name, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } else if r.Method == http.MethodGet { + _, err := w.Write([]byte("[]")) + Expect(err).NotTo(HaveOccurred()) + } + }) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(networkCreated).To(BeTrue()) + + Expect(k8sClient.Get(ctx, typeNamespacedName, nbroutingpeer)).To(Succeed()) + Expect(nbroutingpeer.Status.NetworkID).NotTo(BeNil()) + Expect(*nbroutingpeer.Status.NetworkID).To(Equal("test")) + }) + }) + When("Network exists", func() { + BeforeEach(func() { + mux.HandleFunc("/api/networks", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + resp := []api.Network{ + { + Id: "test", + Description: &networkDescription, + Name: controllerReconciler.ClusterName, + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + nbroutingpeer.Status.NetworkID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + }) + Describe("Network Router changes", func() { + BeforeEach(func() { + group := &netbirdiov1.NBGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: typeNamespacedName.Name, + Namespace: typeNamespacedName.Namespace, + }, + Spec: netbirdiov1.NBGroupSpec{ + Name: controllerReconciler.ClusterName, + }, + } + Expect(k8sClient.Create(ctx, group)).To(Succeed()) + + group.Status.GroupID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, group)).To(Succeed()) + + nbroutingpeer.Status.SetupKeyID = util.Ptr("skid") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + mux.HandleFunc("/api/setup-keys/skid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + resp := api.SetupKey{ + Id: "skid", + Revoked: false, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: typeNamespacedName.Namespace, + Name: typeNamespacedName.Name, + }, + Data: map[string][]byte{ + "setupKey": []byte("SuperSecret"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + }) + + When("Network Router doesn't exist", func() { + It("should create network router", func() { + routerCreated := false + mux.HandleFunc("/api/networks/test/routers", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPost { + routerCreated = true + var req api.PostApiNetworksNetworkIdRoutersJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(json.Unmarshal(bs, &req)).To(Succeed()) + Expect(req.Enabled).To(BeTrue()) + Expect(req.Masquerade).To(BeTrue()) + Expect(req.Metric).To(Equal(9999)) + Expect(req.PeerGroups).NotTo(BeNil()) + Expect(*req.PeerGroups).To(ConsistOf([]string{"test"})) + + resp := api.NetworkRouter{ + Id: "test", + Enabled: true, + Masquerade: true, + Metric: 9999, + PeerGroups: req.PeerGroups, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } else if r.Method == http.MethodGet { + resp := []api.NetworkRouter{} + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(routerCreated).To(BeTrue()) + + Expect(k8sClient.Get(ctx, typeNamespacedName, nbroutingpeer)).To(Succeed()) + Expect(nbroutingpeer.Status.RouterID).NotTo(BeNil()) + Expect(*nbroutingpeer.Status.RouterID).To(Equal("test")) + }) + }) + When("Network Router is out-of-date", func() { + It("should update network router", func() { + nbroutingpeer.Status.RouterID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + routerUpdated := false + mux.HandleFunc("/api/networks/test/routers", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + resp := []api.NetworkRouter{ + { + Id: "test", + Enabled: false, + Masquerade: false, + Metric: 0, + PeerGroups: &[]string{}, + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + mux.HandleFunc("/api/networks/test/routers/test", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPut { + routerUpdated = true + var req api.PutApiNetworksNetworkIdRoutersRouterIdJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(json.Unmarshal(bs, &req)).To(Succeed()) + Expect(req.Enabled).To(BeTrue()) + Expect(req.Masquerade).To(BeTrue()) + Expect(req.Metric).To(Equal(9999)) + Expect(req.PeerGroups).NotTo(BeNil()) + Expect(*req.PeerGroups).To(ConsistOf([]string{"test"})) + + resp := api.NetworkRouter{ + Id: "test", + Enabled: true, + Masquerade: true, + Metric: 9999, + PeerGroups: req.PeerGroups, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(routerUpdated).To(BeTrue()) + + Expect(k8sClient.Get(ctx, typeNamespacedName, nbroutingpeer)).To(Succeed()) + Expect(nbroutingpeer.Status.RouterID).NotTo(BeNil()) + Expect(*nbroutingpeer.Status.RouterID).To(Equal("test")) + }) + }) + }) + When("Network Router exists", func() { + BeforeEach(func() { + nbroutingpeer.Status.RouterID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + mux.HandleFunc("/api/networks/test/routers", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + resp := []api.NetworkRouter{ + { + Id: "test", + Enabled: true, + Masquerade: true, + Metric: 9999, + PeerGroups: &[]string{"test"}, + }, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + }) + When("Group doesn't exist", func() { + BeforeEach(func() { + nbroutingpeer.Status.SetupKeyID = util.Ptr("skid") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + mux.HandleFunc("/api/setup-keys/skid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + resp := api.SetupKey{ + Id: "skid", + Revoked: false, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: typeNamespacedName.Namespace, + Name: typeNamespacedName.Name, + }, + Data: map[string][]byte{ + "setupKey": []byte("SuperSecret"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + }) + It("should create group and requeue to get its ID", func() { + res, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(BeNumerically(">", 0)) + + group := &netbirdiov1.NBGroup{} + Expect(k8sClient.Get(ctx, typeNamespacedName, group)).To(Succeed()) + Expect(group.Spec.Name).To(Equal(controllerReconciler.ClusterName)) + + group.Status.GroupID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, group)).To(Succeed()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + }) + }) + When("Group exists", func() { + BeforeEach(func() { + group := &netbirdiov1.NBGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: typeNamespacedName.Name, + Namespace: typeNamespacedName.Namespace, + Finalizers: []string{"netbird.io/routing-peer-cleanup", "netbird.io/group-cleanup"}, + }, + Spec: netbirdiov1.NBGroupSpec{ + Name: controllerReconciler.ClusterName, + }, + } + Expect(k8sClient.Create(ctx, group)).To(Succeed()) + + group.Status.GroupID = util.Ptr("test") + Expect(k8sClient.Status().Update(ctx, group)).To(Succeed()) + }) + + Describe("Setup Key Behavior", func() { + When("Setup key doesn't exist", func() { + It("should create setup key and save it in Secret", func() { + setupKeyCreated := false + mux.HandleFunc("/api/setup-keys", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPost { + setupKeyCreated = true + var req api.PostApiSetupKeysJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(json.Unmarshal(bs, &req)).To(Succeed()) + Expect(req.AutoGroups).To(ConsistOf([]string{"test"})) + Expect(req.Ephemeral).To(BeEquivalentTo(util.Ptr(true))) + Expect(req.ExpiresIn).To(BeZero()) + Expect(req.Name).To(Equal(controllerReconciler.ClusterName)) + Expect(req.Type).To(Equal("reusable")) + Expect(req.UsageLimit).To(BeZero()) + resp := api.SetupKeyClear{ + AutoGroups: req.AutoGroups, + Ephemeral: *req.Ephemeral, + Id: "test", + Key: "SuperSecretKey", + Name: req.Name, + Valid: true, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(setupKeyCreated).To(BeTrue()) + + secret := &corev1.Secret{} + Expect(k8sClient.Get(ctx, typeNamespacedName, secret)).To(Succeed()) + Expect(secret.Data).To(HaveKey("setupKey")) + Expect(secret.Data["setupKey"]).To(BeEquivalentTo([]byte("SuperSecretKey"))) + }) + }) + When("Setup key exists but secret is invalid", func() { + It("should delete old setup key and requeue to regenerate", func() { + setupKeyCreated := false + mux.HandleFunc("/api/setup-keys", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPost { + setupKeyCreated = true + var req api.PostApiSetupKeysJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(json.Unmarshal(bs, &req)).To(Succeed()) + Expect(req.AutoGroups).To(ConsistOf([]string{"test"})) + Expect(req.Ephemeral).To(BeEquivalentTo(util.Ptr(true))) + Expect(req.ExpiresIn).To(BeZero()) + Expect(req.Name).To(Equal(controllerReconciler.ClusterName)) + Expect(req.Type).To(Equal("reusable")) + Expect(req.UsageLimit).To(BeZero()) + resp := api.SetupKeyClear{ + AutoGroups: req.AutoGroups, + Ephemeral: *req.Ephemeral, + Id: "test", + Key: "SuperSecretKey", + Name: req.Name, + Valid: true, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + setupKeyDeleted := false + mux.HandleFunc("/api/setup-keys/skid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + resp := api.SetupKey{ + Id: "skid", + Revoked: false, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } else if r.Method == http.MethodDelete { + setupKeyDeleted = true + _, err := w.Write([]byte(`{}`)) + Expect(err).NotTo(HaveOccurred()) + } + }) + + nbroutingpeer.Status.SetupKeyID = util.Ptr("skid") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + res, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Requeue).To(BeTrue()) + Expect(setupKeyDeleted).To(BeTrue()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(setupKeyCreated).To(BeTrue()) + + secret := &corev1.Secret{} + Expect(k8sClient.Get(ctx, typeNamespacedName, secret)).To(Succeed()) + Expect(secret.Data).To(HaveKey("setupKey")) + Expect(secret.Data["setupKey"]).To(BeEquivalentTo([]byte("SuperSecretKey"))) + }) + }) + When("Setup key is revoked", func() { + It("should delete setup key and requeue to recreate", func() { + setupKeyCreated := false + mux.HandleFunc("/api/setup-keys", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPost { + setupKeyCreated = true + var req api.PostApiSetupKeysJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(json.Unmarshal(bs, &req)).To(Succeed()) + Expect(req.AutoGroups).To(ConsistOf([]string{"test"})) + Expect(req.Ephemeral).To(BeEquivalentTo(util.Ptr(true))) + Expect(req.ExpiresIn).To(BeZero()) + Expect(req.Name).To(Equal(controllerReconciler.ClusterName)) + Expect(req.Type).To(Equal("reusable")) + Expect(req.UsageLimit).To(BeZero()) + resp := api.SetupKeyClear{ + AutoGroups: req.AutoGroups, + Ephemeral: *req.Ephemeral, + Id: "test", + Key: "SuperSecretKey", + Name: req.Name, + Valid: true, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + setupKeyDeleted := false + mux.HandleFunc("/api/setup-keys/skid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + resp := api.SetupKey{ + Id: "skid", + Revoked: true, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } else if r.Method == http.MethodDelete { + setupKeyDeleted = true + _, err := w.Write([]byte(`{}`)) + Expect(err).NotTo(HaveOccurred()) + } + }) + + nbroutingpeer.Status.SetupKeyID = util.Ptr("skid") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: typeNamespacedName.Namespace, + Name: typeNamespacedName.Name, + }, + StringData: map[string]string{ + "setupKey": "GoneKey", + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + res, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Requeue).To(BeTrue()) + Expect(setupKeyDeleted).To(BeTrue()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(setupKeyCreated).To(BeTrue()) + + secret = &corev1.Secret{} + Expect(k8sClient.Get(ctx, typeNamespacedName, secret)).To(Succeed()) + Expect(secret.Data).To(HaveKey("setupKey")) + Expect(secret.Data["setupKey"]).To(BeEquivalentTo([]byte("SuperSecretKey"))) + }) + }) + When("Setup key is deleted", func() { + It("should requeue to recreate", func() { + setupKeyCreated := false + mux.HandleFunc("/api/setup-keys", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodPost { + setupKeyCreated = true + var req api.PostApiSetupKeysJSONRequestBody + bs, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(json.Unmarshal(bs, &req)).To(Succeed()) + Expect(req.AutoGroups).To(ConsistOf([]string{"test"})) + Expect(req.Ephemeral).To(BeEquivalentTo(util.Ptr(true))) + Expect(req.ExpiresIn).To(BeZero()) + Expect(req.Name).To(Equal(controllerReconciler.ClusterName)) + Expect(req.Type).To(Equal("reusable")) + Expect(req.UsageLimit).To(BeZero()) + resp := api.SetupKeyClear{ + AutoGroups: req.AutoGroups, + Ephemeral: *req.Ephemeral, + Id: "test", + Key: "SuperSecretKey", + Name: req.Name, + Valid: true, + } + bs, err = json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } + }) + + setupKeyDeleted := false + mux.HandleFunc("/api/setup-keys/skid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + w.WriteHeader(404) + _, err := w.Write([]byte(`{"message": "setup-key skid not found", "code": 404}`)) + Expect(err).NotTo(HaveOccurred()) + } else if r.Method == http.MethodDelete { + setupKeyDeleted = true + _, err := w.Write([]byte(`{}`)) + Expect(err).NotTo(HaveOccurred()) + } + }) + + nbroutingpeer.Status.SetupKeyID = util.Ptr("skid") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: typeNamespacedName.Namespace, + Name: typeNamespacedName.Name, + }, + StringData: map[string]string{ + "setupKey": "GoneKey", + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + res, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Requeue).To(BeTrue()) + Expect(setupKeyDeleted).To(BeFalse()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(setupKeyCreated).To(BeTrue()) + + secret = &corev1.Secret{} + Expect(k8sClient.Get(ctx, typeNamespacedName, secret)).To(Succeed()) + Expect(secret.Data).To(HaveKey("setupKey")) + Expect(secret.Data["setupKey"]).To(BeEquivalentTo([]byte("SuperSecretKey"))) + }) + }) + When("Setup key exists and is valid", func() { + It("should do nothing", func() { + setupKeyDeleted := false + mux.HandleFunc("/api/setup-keys/skid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + if r.Method == http.MethodGet { + resp := api.SetupKey{ + Id: "skid", + Revoked: false, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + } else if r.Method == http.MethodDelete { + setupKeyDeleted = true + _, err := w.Write([]byte(`{}`)) + Expect(err).NotTo(HaveOccurred()) + } + }) + + nbroutingpeer.Status.SetupKeyID = util.Ptr("skid") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: typeNamespacedName.Namespace, + Name: typeNamespacedName.Name, + }, + StringData: map[string]string{ + "setupKey": "OriginalKey", + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + res, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Requeue).To(BeFalse()) + Expect(setupKeyDeleted).To(BeFalse()) + + secret = &corev1.Secret{} + Expect(k8sClient.Get(ctx, typeNamespacedName, secret)).To(Succeed()) + Expect(secret.Data).To(HaveKey("setupKey")) + Expect(secret.Data["setupKey"]).To(BeEquivalentTo([]byte("OriginalKey"))) + }) + }) + }) + Describe("Deployment Behavior", func() { + BeforeEach(func() { + nbroutingpeer.Status.SetupKeyID = util.Ptr("skid") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + mux.HandleFunc("/api/setup-keys/skid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + resp := api.SetupKey{ + Id: "skid", + Revoked: false, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: typeNamespacedName.Namespace, + Name: typeNamespacedName.Name, + }, + Data: map[string][]byte{ + "setupKey": []byte("SuperSecret"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + }) + + When("Deployment doesn't exist", func() { + It("should create deployment", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, typeNamespacedName, deployment)).To(Succeed()) + Expect(deployment.OwnerReferences).To(HaveLen(1)) + Expect(deployment.Spec.Replicas).To(BeEquivalentTo(util.Ptr(int32(0)))) + Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(deployment.Spec.Template.Spec.Containers[0].Image).To(Equal(controllerReconciler.ClientImage)) + }) + }) + When("Deployment is out-of-date", func() { + It("should update deployment", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, typeNamespacedName, deployment)).To(Succeed()) + deployment.Spec.Replicas = util.Ptr(int32(15)) + Expect(k8sClient.Update(ctx, deployment)).To(Succeed()) + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + deployment = &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, typeNamespacedName, deployment)).To(Succeed()) + Expect(deployment.Spec.Replicas).To(BeEquivalentTo(util.Ptr(int32(0)))) + }) + }) + When("Deployment is up-to-date", func() { + It("should ", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, typeNamespacedName, deployment)).To(Succeed()) + resourceVersion := deployment.ResourceVersion + + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + deployment = &appsv1.Deployment{} + Expect(k8sClient.Get(ctx, typeNamespacedName, deployment)).To(Succeed()) + Expect(deployment.ResourceVersion).To(Equal(resourceVersion)) + }) + }) + }) + When("NBRoutingPeer is set for deletion", func() { + networkDeleted := false + routerDeleted := false + BeforeEach(func() { + networkDeleted = false + routerDeleted = false + nbroutingpeer.Status.SetupKeyID = util.Ptr("skid") + Expect(k8sClient.Status().Update(ctx, nbroutingpeer)).To(Succeed()) + + mux.HandleFunc("/api/setup-keys/skid", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + resp := api.SetupKey{ + Id: "skid", + Revoked: false, + } + bs, err := json.Marshal(resp) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(bs) + Expect(err).NotTo(HaveOccurred()) + }) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: typeNamespacedName.Namespace, + Name: typeNamespacedName.Name, + }, + Data: map[string][]byte{ + "setupKey": []byte("SuperSecret"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + mux.HandleFunc("/api/networks/test", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + Expect(r.Method).To(Equal(http.MethodDelete)) + _, err = w.Write([]byte(`{}`)) + Expect(err).NotTo(HaveOccurred()) + networkDeleted = true + }) + + mux.HandleFunc("/api/networks/test/routers/test", func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + Expect(r.Method).To(Equal(http.MethodDelete)) + _, err = w.Write([]byte(`{}`)) + Expect(err).NotTo(HaveOccurred()) + routerDeleted = true + }) + }) + + It("should remove finalizer from NBGroup", func() { + Expect(k8sClient.Delete(ctx, nbroutingpeer)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + group := &netbirdiov1.NBGroup{} + Expect(k8sClient.Get(ctx, typeNamespacedName, group)).To(Succeed()) + Expect(group.Finalizers).NotTo(ContainElement("netbird.io/routing-peer-cleanup")) + }) + + It("should delete Network Router", func() { + Expect(k8sClient.Delete(ctx, nbroutingpeer)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(routerDeleted).To(BeTrue()) + }) + + It("should delete Network", func() { + Expect(k8sClient.Delete(ctx, nbroutingpeer)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(networkDeleted).To(BeTrue()) + }) + + It("should delete deployment", func() { + Expect(k8sClient.Delete(ctx, nbroutingpeer)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, typeNamespacedName, deployment) + Expect(errors.IsNotFound(err)).To(BeTrue()) + }) + + It("should delete any hanging NBResources", func() { + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: typeNamespacedName.Name, + Namespace: typeNamespacedName.Namespace, + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "test", + NetworkID: *nbroutingpeer.Status.NetworkID, + Address: "test", + Groups: []string{"grp"}, + }, + } + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, nbroutingpeer)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + nbResource = &netbirdiov1.NBResource{} + err = k8sClient.Get(ctx, typeNamespacedName, nbResource) + Expect(errors.IsNotFound(err)).To(BeTrue()) + }) + }) + }) }) - Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. }) }) }) diff --git a/internal/controller/service_controller.go b/internal/controller/service_controller.go index 0285275..59e870f 100644 --- a/internal/controller/service_controller.go +++ b/internal/controller/service_controller.go @@ -157,7 +157,8 @@ func (r *ServiceReconciler) exposeService(ctx context.Context, req ctrl.Request, return ctrl.Result{}, err } - nbrsErr := r.reconcileNBResource(&nbResource, req, svc, routingPeer) + originalNBResource := nbResource.DeepCopy() + nbrsErr := r.reconcileNBResource(&nbResource, req, svc, routingPeer, logger) if nbrsErr != nil { return ctrl.Result{}, nbrsErr } @@ -168,7 +169,7 @@ func (r *ServiceReconciler) exposeService(ctx context.Context, req ctrl.Request, logger.Error(errKubernetesAPI, "error creating NBResource", "err", err) return ctrl.Result{}, err } - } else { + } else if !originalNBResource.Spec.Equal(nbResource.Spec) { err = r.Client.Update(ctx, &nbResource) if err != nil { logger.Error(errKubernetesAPI, "error updating NBResource", "err", err) @@ -180,7 +181,7 @@ func (r *ServiceReconciler) exposeService(ctx context.Context, req ctrl.Request, } // reconcileNBResource ensures NBResource settings are in-line with Service definition and annotations -func (r *ServiceReconciler) reconcileNBResource(nbResource *netbirdiov1.NBResource, req ctrl.Request, svc corev1.Service, routingPeer netbirdiov1.NBRoutingPeer) error { +func (r *ServiceReconciler) reconcileNBResource(nbResource *netbirdiov1.NBResource, req ctrl.Request, svc corev1.Service, routingPeer netbirdiov1.NBRoutingPeer, logger logr.Logger) error { groups := []string{fmt.Sprintf("%s-%s-%s", r.ClusterName, req.Namespace, req.Name)} if v, ok := svc.Annotations[serviceGroupsAnnotation]; ok { groups = nil @@ -202,50 +203,65 @@ func (r *ServiceReconciler) reconcileNBResource(nbResource *netbirdiov1.NBResour nbResource.Spec.Address = fmt.Sprintf("%s.%s.%s", svc.Name, svc.Namespace, r.ClusterDNS) nbResource.Spec.Groups = groups - if v, ok := svc.Annotations[servicePolicyAnnotation]; ok { - nbResource.Spec.PolicyName = v - var filterProtocols []string - if v, ok := svc.Annotations[serviceProtocolAnnotation]; ok { - filterProtocols = []string{v} + if _, ok := svc.Annotations[servicePolicyAnnotation]; ok { + err := r.applyPolicy(nbResource, svc, logger) + if err != nil { + return err } - var filterPorts []int32 - if v, ok := svc.Annotations[servicePortsAnnotation]; ok { - for _, v := range strings.Split(v, ",") { - port, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return err - } + } else if nbResource.Spec.PolicyName != "" { + nbResource.Spec.PolicyName = "" + nbResource.Spec.TCPPorts = nil + nbResource.Spec.UDPPorts = nil + } - filterPorts = append(filterPorts, int32(port)) - } - } + return nil +} - for _, p := range svc.Spec.Ports { - if len(filterProtocols) > 0 && !util.Contains(filterProtocols, string(p.Protocol)) { - continue - } - if len(filterPorts) > 0 && !util.Contains(filterPorts, p.Port) { - continue - } - switch p.Protocol { - case corev1.ProtocolSCTP: - if !util.Contains(nbResource.Spec.TCPPorts, p.Port) { - nbResource.Spec.TCPPorts = append(nbResource.Spec.TCPPorts, p.Port) - } - case corev1.ProtocolTCP: - if !util.Contains(nbResource.Spec.TCPPorts, p.Port) { - nbResource.Spec.TCPPorts = append(nbResource.Spec.TCPPorts, p.Port) - } - case corev1.ProtocolUDP: - if !util.Contains(nbResource.Spec.UDPPorts, p.Port) { - nbResource.Spec.UDPPorts = append(nbResource.Spec.UDPPorts, p.Port) - } - default: - return errUnknownProtocol +func (r *ServiceReconciler) applyPolicy(nbResource *netbirdiov1.NBResource, svc corev1.Service, logger logr.Logger) error { + nbResource.Spec.PolicyName = svc.Annotations[servicePolicyAnnotation] + var filterProtocols []string + if v, ok := svc.Annotations[serviceProtocolAnnotation]; ok { + filterProtocols = []string{v} + } + var filterPorts []int32 + if v, ok := svc.Annotations[servicePortsAnnotation]; ok { + for _, v := range strings.Split(v, ",") { + port, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return err } + + filterPorts = append(filterPorts, int32(port)) + } + } + + for _, p := range svc.Spec.Ports { + switch p.Protocol { + case corev1.ProtocolTCP: + if (len(filterPorts) > 0 && !util.Contains(filterPorts, p.Port)) || (len(filterProtocols) > 0 && !util.Contains(filterProtocols, "tcp")) { + if util.Contains(nbResource.Spec.TCPPorts, p.Port) { + nbResource.Spec.TCPPorts = util.Without(nbResource.Spec.TCPPorts, p.Port) + } + continue + } + if !util.Contains(nbResource.Spec.TCPPorts, p.Port) { + nbResource.Spec.TCPPorts = append(nbResource.Spec.TCPPorts, p.Port) + } + case corev1.ProtocolUDP: + if (len(filterPorts) > 0 && !util.Contains(filterPorts, p.Port)) || (len(filterProtocols) > 0 && !util.Contains(filterProtocols, "udp")) { + if util.Contains(nbResource.Spec.UDPPorts, p.Port) { + nbResource.Spec.UDPPorts = util.Without(nbResource.Spec.UDPPorts, p.Port) + } + continue + } + if !util.Contains(nbResource.Spec.UDPPorts, p.Port) { + nbResource.Spec.UDPPorts = append(nbResource.Spec.UDPPorts, p.Port) + } + default: + logger.Info("Unsupported protocol %v", p.Protocol) + continue } } - // TODO: Handle removed policy name return nil } diff --git a/internal/controller/service_controller_test.go b/internal/controller/service_controller_test.go index 0067a76..945b02b 100644 --- a/internal/controller/service_controller_test.go +++ b/internal/controller/service_controller_test.go @@ -1,17 +1,473 @@ package controller import ( + netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" + "github.com/netbirdio/kubernetes-operator/internal/util" . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) var _ = Describe("Service Controller", func() { Context("When reconciling a resource", func() { + typeNamespacedName := types.NamespacedName{ + Namespace: "default", + Name: "test-resource", + } + const policyName = "test" + var service *corev1.Service - It("should successfully reconcile the resource", func() { - Skip("Not implemented yet") + var controllerReconciler *ServiceReconciler - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. + BeforeEach(func() { + service = &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-resource", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "a", + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt(80), + }, + { + Name: "b", + Protocol: corev1.ProtocolTCP, + Port: 443, + TargetPort: intstr.FromInt(443), + }, + { + Name: "c", + Protocol: corev1.ProtocolUDP, + Port: 80, + TargetPort: intstr.FromInt(80), + }, + { + Name: "d", + Protocol: corev1.ProtocolUDP, + Port: 443, + TargetPort: intstr.FromInt(443), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, service)).To(Succeed()) + controllerReconciler = &ServiceReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ClusterName: "kubernetes", + NamespacedNetworks: false, + ClusterDNS: "svc.cluster.local", + ControllerNamespace: "default", + } + }) + + AfterEach(func() { + svc := &corev1.Service{} + err := k8sClient.Get(ctx, typeNamespacedName, svc) + if !errors.IsNotFound(err) { + if len(svc.Finalizers) > 0 { + svc.Finalizers = nil + Expect(k8sClient.Update(ctx, svc)).To(Succeed()) + } + + err := k8sClient.Delete(ctx, svc) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + + nbrp := &netbirdiov1.NBRoutingPeer{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "router"}, nbrp) + if !errors.IsNotFound(err) { + if len(nbrp.Finalizers) > 0 { + nbrp.Finalizers = nil + Expect(k8sClient.Update(ctx, nbrp)).To(Succeed()) + } + + err := k8sClient.Delete(ctx, nbrp) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + + nbResource := &netbirdiov1.NBResource{} + err = k8sClient.Get(ctx, typeNamespacedName, nbResource) + if !errors.IsNotFound(err) { + if len(nbResource.Finalizers) > 0 { + nbResource.Finalizers = nil + Expect(k8sClient.Update(ctx, nbResource)).To(Succeed()) + } + + err := k8sClient.Delete(ctx, nbResource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + + When("Service is not already exposed", func() { + When("Service should not be exposed", func() { + It("should change nothing", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient.Get(ctx, typeNamespacedName, service)).To(Succeed()) + Expect(service.Finalizers).To(BeEmpty()) + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).NotTo(Succeed()) + }) + }) + When("NBRoutingPeer doesn't exist", func() { + BeforeEach(func() { + if service.Annotations == nil { + service.Annotations = make(map[string]string) + } + service.Annotations[ServiceExposeAnnotation] = "trueish" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + }) + + It("should create NBRoutingPeer and requeue until network ID is available", func() { + res, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).NotTo(BeZero()) + nbrp := &netbirdiov1.NBRoutingPeer{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: typeNamespacedName.Namespace, Name: "router"}, nbrp)).To(Succeed()) + res, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).NotTo(BeZero()) + nbrp.Status.NetworkID = util.Ptr(policyName) + Expect(k8sClient.Status().Update(ctx, nbrp)).To(Succeed()) + res, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.RequeueAfter).To(BeZero()) + }) + }) + When("NBRoutingPeer exists", func() { + BeforeEach(func() { + nbrp := &netbirdiov1.NBRoutingPeer{ + ObjectMeta: v1.ObjectMeta{ + Namespace: typeNamespacedName.Namespace, + Name: "router", + }, + Spec: netbirdiov1.NBRoutingPeerSpec{}, + } + Expect(k8sClient.Create(ctx, nbrp)).To(Succeed()) + + nbrp.Status.NetworkID = util.Ptr(policyName) + Expect(k8sClient.Status().Update(ctx, nbrp)).To(Succeed()) + }) + When("Service should be exposed", func() { + BeforeEach(func() { + if service.Annotations == nil { + service.Annotations = make(map[string]string) + } + service.Annotations[ServiceExposeAnnotation] = "true" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + }) + It("should add finalizer to service object", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient.Get(ctx, typeNamespacedName, service)).To(Succeed()) + Expect(service.Finalizers).To(ContainElement("netbird.io/cleanup")) + }) + When("nothing else is specified", func() { + It("should create NBResource with default values", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.Address).To(Equal(typeNamespacedName.Name + "." + typeNamespacedName.Namespace + "." + controllerReconciler.ClusterDNS)) + Expect(nbResource.Spec.Groups).To(ConsistOf([]string{controllerReconciler.ClusterName + "-" + typeNamespacedName.Namespace + "-" + typeNamespacedName.Name})) + Expect(nbResource.Spec.Name).To(Equal(typeNamespacedName.Namespace + "-" + typeNamespacedName.Name)) + Expect(nbResource.Spec.NetworkID).To(Equal(policyName)) + Expect(nbResource.Spec.PolicyName).To(BeEmpty()) + Expect(nbResource.Spec.TCPPorts).To(BeEmpty()) + Expect(nbResource.Spec.UDPPorts).To(BeEmpty()) + }) + }) + When("policy is specified", func() { + BeforeEach(func() { + service.Annotations[servicePolicyAnnotation] = policyName + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + }) + When("nothing is restricted", func() { + It("should create NBResource with policy", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.PolicyName).To(Equal(policyName)) + Expect(nbResource.Spec.TCPPorts).To(ConsistOf([]int32{443, 80})) + Expect(nbResource.Spec.UDPPorts).To(ConsistOf([]int32{443, 80})) + }) + }) + When("ports are restricted", func() { + It("should create NBResource with policy and only specified ports", func() { + service.Annotations[servicePortsAnnotation] = "80" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.PolicyName).To(Equal(policyName)) + Expect(nbResource.Spec.TCPPorts).To(ConsistOf([]int32{80})) + Expect(nbResource.Spec.UDPPorts).To(ConsistOf([]int32{80})) + }) + }) + When("protocol is restricted", func() { + It("should create NBResource with policy and only specified protocol", func() { + service.Annotations[serviceProtocolAnnotation] = "tcp" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.PolicyName).To(Equal(policyName)) + Expect(nbResource.Spec.TCPPorts).To(ConsistOf([]int32{80, 443})) + Expect(nbResource.Spec.UDPPorts).To(BeEmpty()) + }) + }) + }) + When("resource name is specified", func() { + It("should create NBResource with specified name", func() { + service.Annotations[serviceResourceAnnotation] = "meow" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.Name).To(Equal("meow")) + }) + }) + When("resource groups specified", func() { + It("should create NBResource with specified groups", func() { + service.Annotations[serviceGroupsAnnotation] = "meow, wow ,test" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.Groups).To(ConsistOf([]string{"meow", "wow", policyName})) + }) + }) + }) + }) + }) + When("Service is already exposed", func() { + BeforeEach(func() { + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: v1.ObjectMeta{ + Name: typeNamespacedName.Name, + Namespace: typeNamespacedName.Namespace, + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: typeNamespacedName.Namespace + "-" + typeNamespacedName.Name, + Address: typeNamespacedName.Name + "." + typeNamespacedName.Namespace + "." + controllerReconciler.ClusterDNS, + Groups: []string{controllerReconciler.ClusterName + "-" + typeNamespacedName.Namespace + "-" + typeNamespacedName.Name}, + NetworkID: policyName, + }, + } + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + + if service.Annotations == nil { + service.Annotations = make(map[string]string) + } + service.Annotations[ServiceExposeAnnotation] = "true" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + + nbrp := &netbirdiov1.NBRoutingPeer{ + ObjectMeta: v1.ObjectMeta{ + Namespace: typeNamespacedName.Namespace, + Name: "router", + }, + Spec: netbirdiov1.NBRoutingPeerSpec{}, + } + Expect(k8sClient.Create(ctx, nbrp)).To(Succeed()) + + nbrp.Status.NetworkID = util.Ptr(policyName) + Expect(k8sClient.Status().Update(ctx, nbrp)).To(Succeed()) + }) + + When("Service should not be exposed", func() { + BeforeEach(func() { + delete(service.Annotations, ServiceExposeAnnotation) + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + }) + It("should delete NBResource", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + nbResource := &netbirdiov1.NBResource{} + err = k8sClient.Get(ctx, typeNamespacedName, nbResource) + if !errors.IsNotFound(err) { + Expect(nbResource.DeletionTimestamp).NotTo(BeNil()) + } + }) + It("should remove finalizer from Service", func() { + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient.Get(ctx, typeNamespacedName, service)).To(Succeed()) + Expect(service.Finalizers).NotTo(ContainElement("netbird.io/cleanup")) + }) + }) + When("Nothing changes", func() { + It("should do nothing", func() { + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + resourceVersion := nbResource.ResourceVersion + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbResource = &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(resourceVersion).To(BeEquivalentTo(nbResource.ResourceVersion)) + }) + }) + When("policy changes", func() { + It("should update policy in NBResource spec", func() { + service.Annotations[servicePolicyAnnotation] = policyName + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.PolicyName).To(Equal(policyName)) + }) + }) + When("policy is removed", func() { + It("should remove policy in NBResource spec", func() { + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + nbResource.Spec.PolicyName = policyName + Expect(k8sClient.Update(ctx, nbResource)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbResource = &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.PolicyName).To(Equal("")) + }) + }) + When("policy ports changes", func() { + It("should update ports in NBResource spec", func() { + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + nbResource.Spec.PolicyName = policyName + nbResource.Spec.TCPPorts = []int32{443, 80} + nbResource.Spec.UDPPorts = []int32{443, 80} + Expect(k8sClient.Update(ctx, nbResource)).To(Succeed()) + + service.Annotations[servicePolicyAnnotation] = policyName + service.Annotations[servicePortsAnnotation] = "80" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbResource = &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.TCPPorts).To(ConsistOf([]int32{80})) + Expect(nbResource.Spec.UDPPorts).To(ConsistOf([]int32{80})) + }) + }) + When("policy protocol changes", func() { + It("should update protocol in NBResource spec", func() { + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + nbResource.Spec.PolicyName = policyName + nbResource.Spec.TCPPorts = []int32{443, 80} + nbResource.Spec.UDPPorts = []int32{443, 80} + Expect(k8sClient.Update(ctx, nbResource)).To(Succeed()) + + service.Annotations[servicePolicyAnnotation] = policyName + service.Annotations[serviceProtocolAnnotation] = "tcp" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbResource = &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.TCPPorts).To(ConsistOf([]int32{80, 443})) + Expect(nbResource.Spec.UDPPorts).To(BeEmpty()) + }) + }) + When("resource name changes", func() { + It("should update name in NBResource spec", func() { + service.Annotations[serviceResourceAnnotation] = "meow" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.Name).To(Equal("meow")) + }) + }) + When("resource groups changes", func() { + It("should update groups in NBResource spec", func() { + service.Annotations[serviceGroupsAnnotation] = "a7medmo7sen, pewpewpew" + Expect(k8sClient.Update(ctx, service)).To(Succeed()) + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + nbResource := &netbirdiov1.NBResource{} + Expect(k8sClient.Get(ctx, typeNamespacedName, nbResource)).To(Succeed()) + Expect(nbResource.Spec.Groups).To(ConsistOf([]string{"a7medmo7sen", "pewpewpew"})) + }) + }) }) }) }) diff --git a/internal/webhook/v1/nbgroup_webhook.go b/internal/webhook/v1/nbgroup_webhook.go index 357ee42..fa030df 100644 --- a/internal/webhook/v1/nbgroup_webhook.go +++ b/internal/webhook/v1/nbgroup_webhook.go @@ -14,7 +14,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" - netbird "github.com/netbirdio/netbird/management/client/rest" ) // nolint:unused @@ -22,17 +21,16 @@ import ( var nbgrouplog = logf.Log.WithName("nbgroup-resource") // SetupNBGroupWebhookWithManager registers the webhook for NBGroup in the manager. -func SetupNBGroupWebhookWithManager(mgr ctrl.Manager, managementURL, apiKey string) error { +func SetupNBGroupWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr).For(&netbirdiov1.NBGroup{}). - WithValidator(&NBGroupCustomValidator{netbird: netbird.New(managementURL, apiKey), client: mgr.GetClient()}). + WithValidator(&NBGroupCustomValidator{client: mgr.GetClient()}). Complete() } // NBGroupCustomValidator struct is responsible for validating the NBGroup resource // when it is created, updated, or deleted. type NBGroupCustomValidator struct { - netbird *netbird.Client - client client.Client + client client.Client } var _ webhook.CustomValidator = &NBGroupCustomValidator{} diff --git a/internal/webhook/v1/nbgroup_webhook_test.go b/internal/webhook/v1/nbgroup_webhook_test.go index d922061..9cb01de 100644 --- a/internal/webhook/v1/nbgroup_webhook_test.go +++ b/internal/webhook/v1/nbgroup_webhook_test.go @@ -3,9 +3,11 @@ package v1 import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" - // TODO (user): Add any additional imports if needed ) var _ = Describe("NBGroup Webhook", func() { @@ -16,41 +18,154 @@ var _ = Describe("NBGroup Webhook", func() { ) BeforeEach(func() { - Skip("Not implemented yet") obj = &netbirdiov1.NBGroup{} oldObj = &netbirdiov1.NBGroup{} - validator = NBGroupCustomValidator{} + validator = NBGroupCustomValidator{ + client: k8sClient, + } Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") - // TODO (user): Add any setup logic common to all tests }) AfterEach(func() { - // TODO (user): Add any teardown logic common to all tests }) Context("When creating or updating NBGroup under Validating Webhook", func() { - // TODO (user): Add logic for validating webhooks - // Example: - // It("Should deny creation if a required field is missing", func() { - // By("simulating an invalid creation scenario") - // obj.SomeRequiredField = "" - // Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred()) - // }) - // - // It("Should admit creation if all required fields are present", func() { - // By("simulating an invalid creation scenario") - // obj.SomeRequiredField = "valid_value" - // Expect(validator.ValidateCreate(ctx, obj)).To(BeNil()) - // }) - // - // It("Should validate updates correctly", func() { - // By("simulating a valid update scenario") - // oldObj.SomeRequiredField = "updated_value" - // obj.SomeRequiredField = "updated_value" - // Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil()) - // }) + It("should allow creation", func() { + Expect(validator.ValidateCreate(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + It("should allow update", func() { + Expect(validator.ValidateUpdate(ctx, oldObj, obj)).Error().NotTo(HaveOccurred()) + }) + When("There are no owners", func() { + It("should allow deletion", func() { + obj = &netbirdiov1.NBGroup{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + Namespace: "default", + OwnerReferences: nil, + }, + } + Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + }) + When("There deleted owners", func() { + It("should allow deletion", func() { + obj = &netbirdiov1.NBGroup{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + Namespace: "default", + OwnerReferences: []v1.OwnerReference{ + { + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: "NBResource", + Name: "notexist", + UID: obj.UID, + }, + }, + }, + } + Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + }) + When("NBResource owner exists", func() { + BeforeEach(func() { + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: v1.ObjectMeta{ + Name: "isexist", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "test1", + NetworkID: "test2", + Address: "test3", + Groups: []string{"test"}, + }, + } + + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + + obj = &netbirdiov1.NBGroup{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + Namespace: "default", + OwnerReferences: []v1.OwnerReference{ + { + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: nbResource.Kind, + Name: nbResource.Name, + UID: nbResource.UID, + }, + }, + }, + } + }) + AfterEach(func() { + nbResource := &netbirdiov1.NBResource{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "isexist"}, nbResource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(nbResource.Finalizers) > 0 { + nbResource.Finalizers = nil + Expect(k8sClient.Update(ctx, nbResource)).To(Succeed()) + } + err = k8sClient.Delete(ctx, nbResource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + It("should deny deletion", func() { + Expect(validator.ValidateDelete(ctx, obj)).Error().To(HaveOccurred()) + }) + }) + When("NBRoutingPeer owner exists", func() { + BeforeEach(func() { + nbrp := &netbirdiov1.NBRoutingPeer{ + ObjectMeta: v1.ObjectMeta{ + Name: "isexist", + Namespace: "default", + }, + Spec: netbirdiov1.NBRoutingPeerSpec{}, + } + + Expect(k8sClient.Create(ctx, nbrp)).To(Succeed()) + + obj = &netbirdiov1.NBGroup{ + ObjectMeta: v1.ObjectMeta{ + Name: "test", + Namespace: "default", + OwnerReferences: []v1.OwnerReference{ + { + APIVersion: netbirdiov1.GroupVersion.Identifier(), + Kind: nbrp.Kind, + Name: nbrp.Name, + UID: nbrp.UID, + }, + }, + }, + } + }) + AfterEach(func() { + nbrp := &netbirdiov1.NBRoutingPeer{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "isexist"}, nbrp) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(nbrp.Finalizers) > 0 { + nbrp.Finalizers = nil + Expect(k8sClient.Update(ctx, nbrp)).To(Succeed()) + } + err = k8sClient.Delete(ctx, nbrp) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + It("should deny deletion", func() { + Expect(validator.ValidateDelete(ctx, obj)).Error().To(HaveOccurred()) + }) + }) }) }) diff --git a/internal/webhook/v1/nbresource_webhook.go b/internal/webhook/v1/nbresource_webhook.go index 66c73e7..0a63fb2 100644 --- a/internal/webhook/v1/nbresource_webhook.go +++ b/internal/webhook/v1/nbresource_webhook.go @@ -5,6 +5,7 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -15,7 +16,6 @@ import ( netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" "github.com/netbirdio/kubernetes-operator/internal/controller" - netbird "github.com/netbirdio/netbird/management/client/rest" ) // nolint:unused @@ -23,17 +23,16 @@ import ( var nbresourcelog = logf.Log.WithName("nbresource-resource") // SetupNBResourceWebhookWithManager registers the webhook for NBResource in the manager. -func SetupNBResourceWebhookWithManager(mgr ctrl.Manager, managementURL, apiKey string) error { +func SetupNBResourceWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr).For(&netbirdiov1.NBResource{}). - WithValidator(&NBResourceCustomValidator{netbird: netbird.New(managementURL, apiKey), client: mgr.GetClient()}). + WithValidator(&NBResourceCustomValidator{client: mgr.GetClient()}). Complete() } // NBResourceCustomValidator struct is responsible for validating the NBResource resource // when it is created, updated, or deleted. type NBResourceCustomValidator struct { - netbird *netbird.Client - client client.Client + client client.Client } var _ webhook.CustomValidator = &NBResourceCustomValidator{} @@ -58,6 +57,9 @@ func (v *NBResourceCustomValidator) ValidateDelete(ctx context.Context, obj runt var svc corev1.Service err := v.client.Get(ctx, types.NamespacedName{Namespace: nbresource.Namespace, Name: nbresource.Name}, &svc) + if errors.IsNotFound(err) { + return nil, nil + } if err != nil { return nil, err } diff --git a/internal/webhook/v1/nbresource_webhook_test.go b/internal/webhook/v1/nbresource_webhook_test.go index 29738be..928c3cf 100644 --- a/internal/webhook/v1/nbresource_webhook_test.go +++ b/internal/webhook/v1/nbresource_webhook_test.go @@ -3,9 +3,13 @@ package v1 import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" - // TODO (user): Add any additional imports if needed ) var _ = Describe("NBResource Webhook", func() { @@ -16,41 +20,103 @@ var _ = Describe("NBResource Webhook", func() { ) BeforeEach(func() { - Skip("Not implemented yet") obj = &netbirdiov1.NBResource{} oldObj = &netbirdiov1.NBResource{} - validator = NBResourceCustomValidator{} - Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") - Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") - Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") - // TODO (user): Add any setup logic common to all tests - }) - - AfterEach(func() { - // TODO (user): Add any teardown logic common to all tests + validator = NBResourceCustomValidator{ + client: k8sClient, + } }) Context("When creating or updating NBResource under Validating Webhook", func() { - // TODO (user): Add logic for validating webhooks - // Example: - // It("Should deny creation if a required field is missing", func() { - // By("simulating an invalid creation scenario") - // obj.SomeRequiredField = "" - // Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred()) - // }) - // - // It("Should admit creation if all required fields are present", func() { - // By("simulating an invalid creation scenario") - // obj.SomeRequiredField = "valid_value" - // Expect(validator.ValidateCreate(ctx, obj)).To(BeNil()) - // }) - // - // It("Should validate updates correctly", func() { - // By("simulating a valid update scenario") - // oldObj.SomeRequiredField = "updated_value" - // obj.SomeRequiredField = "updated_value" - // Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil()) - // }) - }) + It("should allow creation", func() { + Expect(validator.ValidateCreate(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + It("should allow update", func() { + Expect(validator.ValidateUpdate(ctx, oldObj, obj)).Error().NotTo(HaveOccurred()) + }) + When("No services are exposed", func() { + BeforeEach(func() { + obj.Name = "maw" + obj.Namespace = "default" + svc := &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Name: "ne", + Namespace: "default", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt32(80), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + }) + AfterEach(func() { + svc := &netbirdiov1.NBResource{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "ne"}, svc) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(svc.Finalizers) > 0 { + svc.Finalizers = nil + Expect(k8sClient.Update(ctx, svc)).To(Succeed()) + } + err = k8sClient.Delete(ctx, svc) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + It("should allow deletion", func() { + Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + }) + When("A service is exposed", func() { + BeforeEach(func() { + obj.Name = "maw" + obj.Namespace = "default" + svc := &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Name: "maw", + Namespace: "default", + Annotations: map[string]string{ + "netbird.io/expose": "true", + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt32(80), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + }) + AfterEach(func() { + svc := &corev1.Service{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "maw"}, svc) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(svc.Finalizers) > 0 { + svc.Finalizers = nil + Expect(k8sClient.Update(ctx, svc)).To(Succeed()) + } + err = k8sClient.Delete(ctx, svc) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + It("should deny deletion", func() { + Expect(validator.ValidateDelete(ctx, obj)).Error().To(HaveOccurred()) + }) + }) + }) }) diff --git a/internal/webhook/v1/nbroutingpeer_webhook.go b/internal/webhook/v1/nbroutingpeer_webhook.go index 7e829b6..df6fda3 100644 --- a/internal/webhook/v1/nbroutingpeer_webhook.go +++ b/internal/webhook/v1/nbroutingpeer_webhook.go @@ -12,7 +12,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" - netbird "github.com/netbirdio/netbird/management/client/rest" ) // nolint:unused @@ -20,17 +19,16 @@ import ( var nbroutingpeerlog = logf.Log.WithName("nbroutingpeer-resource") // SetupNBRoutingPeerWebhookWithManager registers the webhook for NBRoutingPeer in the manager. -func SetupNBRoutingPeerWebhookWithManager(mgr ctrl.Manager, managementURL, apiKey string) error { +func SetupNBRoutingPeerWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr).For(&netbirdiov1.NBRoutingPeer{}). - WithValidator(&NBRoutingPeerCustomValidator{netbird: netbird.New(managementURL, apiKey), client: mgr.GetClient()}). + WithValidator(&NBRoutingPeerCustomValidator{client: mgr.GetClient()}). Complete() } // NBRoutingPeerCustomValidator struct is responsible for validating the NBRoutingPeer resource // when it is created, updated, or deleted. type NBRoutingPeerCustomValidator struct { - netbird *netbird.Client - client client.Client + client client.Client } var _ webhook.CustomValidator = &NBRoutingPeerCustomValidator{} @@ -63,11 +61,13 @@ func (v *NBRoutingPeerCustomValidator) ValidateDelete(ctx context.Context, obj r return nil, err } + resourceValidator := &NBResourceCustomValidator{client: v.client} + for _, r := range nbResources.Items { if r.Spec.NetworkID == *nbroutingpeer.Status.NetworkID { - err = v.client.Delete(ctx, &r, client.DryRunAll) + _, err = resourceValidator.ValidateDelete(ctx, &r) if err != nil { - return nil, fmt.Errorf("%s/%s: %w", r.Namespace, r.Name, err) + return nil, err } } } diff --git a/internal/webhook/v1/nbroutingpeer_webhook_test.go b/internal/webhook/v1/nbroutingpeer_webhook_test.go index f739f17..cd50344 100644 --- a/internal/webhook/v1/nbroutingpeer_webhook_test.go +++ b/internal/webhook/v1/nbroutingpeer_webhook_test.go @@ -3,9 +3,14 @@ package v1 import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" netbirdiov1 "github.com/netbirdio/kubernetes-operator/api/v1" - // TODO (user): Add any additional imports if needed + "github.com/netbirdio/kubernetes-operator/internal/util" ) var _ = Describe("NBRoutingPeer Webhook", func() { @@ -16,41 +21,222 @@ var _ = Describe("NBRoutingPeer Webhook", func() { ) BeforeEach(func() { - Skip("Not implemented yet") obj = &netbirdiov1.NBRoutingPeer{} oldObj = &netbirdiov1.NBRoutingPeer{} - validator = NBRoutingPeerCustomValidator{} - Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") - Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") - Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") - // TODO (user): Add any setup logic common to all tests - }) - - AfterEach(func() { - // TODO (user): Add any teardown logic common to all tests + validator = NBRoutingPeerCustomValidator{ + client: k8sClient, + } }) Context("When creating or updating NBRoutingPeer under Validating Webhook", func() { - // TODO (user): Add logic for validating webhooks - // Example: - // It("Should deny creation if a required field is missing", func() { - // By("simulating an invalid creation scenario") - // obj.SomeRequiredField = "" - // Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred()) - // }) - // - // It("Should admit creation if all required fields are present", func() { - // By("simulating an invalid creation scenario") - // obj.SomeRequiredField = "valid_value" - // Expect(validator.ValidateCreate(ctx, obj)).To(BeNil()) - // }) - // - // It("Should validate updates correctly", func() { - // By("simulating a valid update scenario") - // oldObj.SomeRequiredField = "updated_value" - // obj.SomeRequiredField = "updated_value" - // Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil()) - // }) + It("should allow creation", func() { + Expect(validator.ValidateCreate(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + It("should allow update", func() { + Expect(validator.ValidateUpdate(ctx, oldObj, obj)).Error().NotTo(HaveOccurred()) + }) + When("No NBResources Exist", func() { + It("should allow deletion", func() { + Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + }) + When("Deleteable NBResources Exist", func() { + BeforeEach(func() { + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: v1.ObjectMeta{ + Name: "isexist", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "test1", + NetworkID: "test2", + Address: "test3", + Groups: []string{"test"}, + }, + } + + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + + obj = &netbirdiov1.NBRoutingPeer{ + Status: netbirdiov1.NBRoutingPeerStatus{ + NetworkID: util.Ptr("test2"), + }, + } + }) + + AfterEach(func() { + nbResource := &netbirdiov1.NBResource{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "isexist"}, nbResource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(nbResource.Finalizers) > 0 { + nbResource.Finalizers = nil + Expect(k8sClient.Update(ctx, nbResource)).To(Succeed()) + } + err = k8sClient.Delete(ctx, nbResource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + It("should allow deletion", func() { + Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + }) + When("Exposed Services for Network Exist", func() { + BeforeEach(func() { + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: v1.ObjectMeta{ + Name: "maw", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "test1", + NetworkID: "test2", + Address: "test3", + Groups: []string{"test"}, + }, + } + + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + + svc := &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Name: "maw", + Namespace: "default", + Annotations: map[string]string{ + "netbird.io/expose": "true", + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt32(80), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + + obj = &netbirdiov1.NBRoutingPeer{ + Status: netbirdiov1.NBRoutingPeerStatus{ + NetworkID: util.Ptr("test2"), + }, + } + }) + + AfterEach(func() { + nbResource := &netbirdiov1.NBResource{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "maw"}, nbResource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(nbResource.Finalizers) > 0 { + nbResource.Finalizers = nil + Expect(k8sClient.Update(ctx, nbResource)).To(Succeed()) + } + err = k8sClient.Delete(ctx, nbResource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + + svc := &corev1.Service{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "maw"}, svc) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(svc.Finalizers) > 0 { + svc.Finalizers = nil + Expect(k8sClient.Update(ctx, svc)).To(Succeed()) + } + err = k8sClient.Delete(ctx, svc) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + It("should deny deletion", func() { + Expect(validator.ValidateDelete(ctx, obj)).Error().To(HaveOccurred()) + }) + }) + When("Exposed NBResources do not belong to network", func() { + BeforeEach(func() { + nbResource := &netbirdiov1.NBResource{ + ObjectMeta: v1.ObjectMeta{ + Name: "maw", + Namespace: "default", + }, + Spec: netbirdiov1.NBResourceSpec{ + Name: "test1", + NetworkID: "test5", + Address: "test3", + Groups: []string{"test"}, + }, + } + + Expect(k8sClient.Create(ctx, nbResource)).To(Succeed()) + + svc := &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Name: "maw", + Namespace: "default", + Annotations: map[string]string{ + "netbird.io/expose": "true", + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Protocol: corev1.ProtocolTCP, + Port: 80, + TargetPort: intstr.FromInt32(80), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, svc)).To(Succeed()) + + obj = &netbirdiov1.NBRoutingPeer{ + Status: netbirdiov1.NBRoutingPeerStatus{ + NetworkID: util.Ptr("test2"), + }, + } + }) + + AfterEach(func() { + nbResource := &netbirdiov1.NBResource{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "maw"}, nbResource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(nbResource.Finalizers) > 0 { + nbResource.Finalizers = nil + Expect(k8sClient.Update(ctx, nbResource)).To(Succeed()) + } + err = k8sClient.Delete(ctx, nbResource) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + + svc := &corev1.Service{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "maw"}, svc) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(svc.Finalizers) > 0 { + svc.Finalizers = nil + Expect(k8sClient.Update(ctx, svc)).To(Succeed()) + } + err = k8sClient.Delete(ctx, svc) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + It("should allow deletion", func() { + Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + }) }) }) diff --git a/internal/webhook/v1/nbsetupkey_webhook_test.go b/internal/webhook/v1/nbsetupkey_webhook_test.go index a4eedc8..e7b9309 100644 --- a/internal/webhook/v1/nbsetupkey_webhook_test.go +++ b/internal/webhook/v1/nbsetupkey_webhook_test.go @@ -6,6 +6,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -30,11 +31,6 @@ var _ = Describe("NBSetupKey Webhook", func() { validator = NBSetupKeyCustomValidator{ client: k8sClient, } - Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") - Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") - }) - - AfterEach(func() { }) Context("When creating or updating NBSetupKey under Validating Webhook", func() { @@ -119,6 +115,96 @@ var _ = Describe("NBSetupKey Webhook", func() { }) }) }) + + Context("Delete", func() { + When("No pods exist with annotation", func() { + BeforeEach(func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "notannotated", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test", Image: "test"}, + }, + }, + } + Expect(k8sClient.Create(ctx, pod)).To(Succeed()) + }) + + AfterEach(func() { + pod := &corev1.Pod{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "notannotated"}, pod) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(pod.Finalizers) > 0 { + pod.Finalizers = nil + Expect(k8sClient.Update(ctx, pod)).To(Succeed()) + } + err = k8sClient.Delete(ctx, pod) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + + It("should allow delete", func() { + obj = &netbirdiov1.NBSetupKey{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + } + Expect(validator.ValidateDelete(ctx, obj)).Error().NotTo(HaveOccurred()) + }) + }) + When("Pods exist with annotation", func() { + BeforeEach(func() { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "annotated", + Annotations: map[string]string{ + setupKeyAnnotation: "test", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test", Image: "test"}, + }, + }, + } + Expect(k8sClient.Create(ctx, pod)).To(Succeed()) + }) + + AfterEach(func() { + pod := &corev1.Pod{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: "default", Name: "annotated"}, pod) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + if len(pod.Finalizers) > 0 { + pod.Finalizers = nil + Expect(k8sClient.Update(ctx, pod)).To(Succeed()) + } + err = k8sClient.Delete(ctx, pod) + if !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + } + }) + + It("should deny delete", func() { + obj = &netbirdiov1.NBSetupKey{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + } + Expect(validator.ValidateDelete(ctx, obj)).Error().To(HaveOccurred()) + }) + }) + }) }) }) diff --git a/internal/webhook/v1/webhook_suite_test.go b/internal/webhook/v1/webhook_suite_test.go index 22173b0..85236e9 100644 --- a/internal/webhook/v1/webhook_suite_test.go +++ b/internal/webhook/v1/webhook_suite_test.go @@ -124,13 +124,13 @@ var _ = BeforeSuite(func() { err = SetupNBSetupKeyWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) - err = SetupNBResourceWebhookWithManager(mgr, "", "") + err = SetupNBResourceWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) - err = SetupNBRoutingPeerWebhookWithManager(mgr, "", "") + err = SetupNBRoutingPeerWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) - err = SetupNBGroupWebhookWithManager(mgr, "", "") + err = SetupNBGroupWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) // +kubebuilder:scaffold:webhook