diff --git a/api/v1alpha1/ratelimit_types.go b/api/v1alpha1/ratelimit_types.go index 1cd3ef99..5b39a417 100644 --- a/api/v1alpha1/ratelimit_types.go +++ b/api/v1alpha1/ratelimit_types.go @@ -28,8 +28,11 @@ type RateLimitSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - // Foo is an example field of RateLimit. Edit RateLimit_types.go to remove/update - Foo string `json:"foo,omitempty"` + Conditions []string `json:"conditions"` + MaxValue int `json:"max_value"` + Namespace string `json:"namespace"` + Seconds int `json:"seconds"` + Variables []string `json:"variables"` } // RateLimitStatus defines the observed state of RateLimit diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 506d8774..d4a48fff 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -128,7 +128,7 @@ func (in *RateLimit) DeepCopyInto(out *RateLimit) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) out.Status = in.Status } @@ -185,6 +185,16 @@ func (in *RateLimitList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RateLimitSpec) DeepCopyInto(out *RateLimitSpec) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Variables != nil { + in, out := &in.Variables, &out.Variables + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimitSpec. diff --git a/config/crd/bases/limitador.3scale.net_ratelimits.yaml b/config/crd/bases/limitador.3scale.net_ratelimits.yaml index 27fe196e..56d09daf 100644 --- a/config/crd/bases/limitador.3scale.net_ratelimits.yaml +++ b/config/crd/bases/limitador.3scale.net_ratelimits.yaml @@ -36,10 +36,26 @@ spec: spec: description: RateLimitSpec defines the desired state of RateLimit properties: - foo: - description: Foo is an example field of RateLimit. Edit RateLimit_types.go - to remove/update + conditions: + items: + type: string + type: array + max_value: + type: integer + namespace: type: string + seconds: + type: integer + variables: + items: + type: string + type: array + required: + - conditions + - max_value + - namespace + - seconds + - variables type: object status: description: RateLimitStatus defines the observed state of RateLimit diff --git a/config/samples/limitador_v1alpha1_ratelimit.yaml b/config/samples/limitador_v1alpha1_ratelimit.yaml index 4e9296a9..c664a6f9 100644 --- a/config/samples/limitador_v1alpha1_ratelimit.yaml +++ b/config/samples/limitador_v1alpha1_ratelimit.yaml @@ -3,5 +3,10 @@ kind: RateLimit metadata: name: ratelimit-sample spec: - # Add fields here - foo: bar + namespace: test_namespace + max_value: 10 + seconds: 60 + conditions: + - "req.method == GET" + variables: + - user_id diff --git a/controllers/limitador_controller_test.go b/controllers/limitador_controller_test.go new file mode 100644 index 00000000..e67b5b1f --- /dev/null +++ b/controllers/limitador_controller_test.go @@ -0,0 +1,179 @@ +package controllers + +import ( + "context" + limitadorv1alpha1 "github.com/3scale/limitador-operator/api/v1alpha1" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "time" +) + +var _ = Describe("Limitador controller", func() { + const ( + LimitadorName = "limitador-test" + LimitadorNamespace = "default" + LimitadorReplicas = 2 + LimitadorImage = "quay.io/3scale/limitador" + LimitadorVersion = "0.3.0" + + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + replicas := LimitadorReplicas + version := LimitadorVersion + limitador := limitadorv1alpha1.Limitador{ + TypeMeta: metav1.TypeMeta{ + Kind: "Limitador", + APIVersion: "limitador.3scale.net/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: LimitadorName, + Namespace: LimitadorNamespace, + }, + Spec: limitadorv1alpha1.LimitadorSpec{ + Replicas: &replicas, + Version: &version, + }, + } + + Context("Creating a new Limitador object", func() { + BeforeEach(func() { + err := k8sClient.Delete(context.TODO(), limitador.DeepCopy()) + Expect(err == nil || errors.IsNotFound(err)) + + Expect(k8sClient.Create(context.TODO(), limitador.DeepCopy())).Should(Succeed()) + }) + + It("Should create a new deployment with the right number of replicas and version", func() { + createdLimitadorDeployment := appsv1.Deployment{} + Eventually(func() bool { + err := k8sClient.Get( + context.TODO(), + types.NamespacedName{ + Namespace: LimitadorNamespace, + Name: LimitadorName, + }, + &createdLimitadorDeployment) + + return err == nil + }, timeout, interval).Should(BeTrue()) + + Expect(*createdLimitadorDeployment.Spec.Replicas).Should( + Equal((int32)(LimitadorReplicas)), + ) + Expect(createdLimitadorDeployment.Spec.Template.Spec.Containers[0].Image).Should( + Equal(LimitadorImage + ":" + LimitadorVersion), + ) + }) + + It("Should create a Limitador service", func() { + createdLimitadorService := v1.Service{} + Eventually(func() bool { + err := k8sClient.Get( + context.TODO(), + types.NamespacedName{ + Namespace: "default", // Hardcoded for now + Name: "limitador", // Hardcoded for now + }, + &createdLimitadorService) + + return err == nil + }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("Deleting a Limitador object", func() { + BeforeEach(func() { + err := k8sClient.Create(context.TODO(), limitador.DeepCopy()) + Expect(err == nil || errors.IsAlreadyExists(err)) + + Expect(k8sClient.Delete(context.TODO(), limitador.DeepCopy())).Should(Succeed()) + }) + + It("Should delete the limitador deployment", func() { + createdLimitadorDeployment := appsv1.Deployment{} + Eventually(func() bool { + err := k8sClient.Get( + context.TODO(), + types.NamespacedName{ + Namespace: LimitadorNamespace, + Name: LimitadorName, + }, + &createdLimitadorDeployment) + + return errors.IsNotFound(err) + }, timeout, interval).Should(BeTrue()) + }) + + It("Should delete the limitador service", func() { + createdLimitadorService := v1.Service{} + Eventually(func() bool { + err := k8sClient.Get( + context.TODO(), + types.NamespacedName{ + Namespace: "default", // Hardcoded for now + Name: "limitador", // Hardcoded for now + }, + &createdLimitadorService) + + return errors.IsNotFound(err) + }, timeout, interval).Should(BeTrue()) + }) + }) + + Context("Updating a limitador object", func() { + BeforeEach(func() { + err := k8sClient.Delete(context.TODO(), limitador.DeepCopy()) + Expect(err == nil || errors.IsNotFound(err)) + + Expect(k8sClient.Create(context.TODO(), limitador.DeepCopy())).Should(Succeed()) + }) + + It("Should modify the limitador deployment", func() { + updatedLimitador := limitadorv1alpha1.Limitador{} + Eventually(func() bool { + err := k8sClient.Get( + context.TODO(), + types.NamespacedName{ + Namespace: LimitadorNamespace, + Name: LimitadorName, + }, + &updatedLimitador) + + return err == nil + }, timeout, interval).Should(BeTrue()) + + replicas = LimitadorReplicas + 1 + updatedLimitador.Spec.Replicas = &replicas + version = "latest" + updatedLimitador.Spec.Version = &version + + Expect(k8sClient.Update(context.TODO(), &updatedLimitador)).Should(Succeed()) + updatedLimitadorDeployment := appsv1.Deployment{} + Eventually(func() bool { + err := k8sClient.Get( + context.TODO(), + types.NamespacedName{ + Namespace: LimitadorNamespace, + Name: LimitadorName, + }, + &updatedLimitadorDeployment) + + if err != nil { + return false + } + + correctReplicas := *updatedLimitadorDeployment.Spec.Replicas == LimitadorReplicas+1 + correctImage := updatedLimitadorDeployment.Spec.Template.Spec.Containers[0].Image == LimitadorImage+":latest" + + return correctReplicas && correctImage + }, timeout, interval).Should(BeTrue()) + }) + }) +}) diff --git a/controllers/ratelimit_controller.go b/controllers/ratelimit_controller.go index 4a500ba5..f0c6634f 100644 --- a/controllers/ratelimit_controller.go +++ b/controllers/ratelimit_controller.go @@ -18,20 +18,56 @@ package controllers import ( "context" - + "github.com/3scale/limitador-operator/pkg/helpers" + "github.com/3scale/limitador-operator/pkg/limitador" "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "net/url" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "strconv" limitadorv1alpha1 "github.com/3scale/limitador-operator/api/v1alpha1" ) +const rateLimitFinalizer = "finalizer.ratelimit.limitador.3scale.net" + +// Assumes that there's only one Limitador per namespace. We might want to +// change this in the future. +type LimitadorServiceDiscovery interface { + URL(namespace string) (*url.URL, error) +} + +type defaultLimitadorServiceDiscovery struct{} + +func (LimitadorServiceDiscovery *defaultLimitadorServiceDiscovery) URL(namespace string) (*url.URL, error) { + serviceUrl := "http://" + limitador.ServiceName + "." + namespace + ".svc.cluster.local:" + + strconv.Itoa(limitador.ServiceHTTPPort) + + return url.Parse(serviceUrl) +} + // RateLimitReconciler reconciles a RateLimit object type RateLimitReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme + Log logr.Logger + Scheme *runtime.Scheme + limitadorDiscovery LimitadorServiceDiscovery +} + +func NewRateLimitReconciler(kubeClient client.Client, logger logr.Logger, scheme *runtime.Scheme) RateLimitReconciler { + limitadorServiceDiscovery := defaultLimitadorServiceDiscovery{} + + return RateLimitReconciler{ + Client: kubeClient, + Log: logger, + Scheme: scheme, + limitadorDiscovery: &limitadorServiceDiscovery, + } } // +kubebuilder:rbac:groups=limitador.3scale.net,resources=ratelimits,verbs=get;list;watch;create;update;patch;delete @@ -39,9 +75,44 @@ type RateLimitReconciler struct { func (r *RateLimitReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { _ = context.Background() - _ = r.Log.WithValues("ratelimit", req.NamespacedName) + reqLogger := r.Log.WithValues("ratelimit", req.NamespacedName) + + limit := &limitadorv1alpha1.RateLimit{} + if err := r.Get(context.TODO(), req.NamespacedName, limit); err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + + reqLogger.Error(err, "Failed to get RateLimit object.") + return ctrl.Result{}, err + } + + isLimitMarkedToBeDeleted := limit.GetDeletionTimestamp() != nil + if isLimitMarkedToBeDeleted { + if helpers.Contains(limit.GetFinalizers(), rateLimitFinalizer) { + if err := r.finalizeRateLimit(limit); err != nil { + return ctrl.Result{}, err + } + + // Remove finalizer. Once all finalizers have been removed, the + // object will be deleted. + controllerutil.RemoveFinalizer(limit, rateLimitFinalizer) + if err := r.Update(context.TODO(), limit); err != nil { + return ctrl.Result{}, err + } + } + + return ctrl.Result{}, nil + } + + if err := r.ensureFinalizerIsAdded(limit, reqLogger); err != nil { + return ctrl.Result{}, err + } - // your logic here + if err := r.createLimitInLimitador(limit); err != nil { + reqLogger.Error(err, "Failed to create rate limit in Limitador.") + return ctrl.Result{}, err + } return ctrl.Result{}, nil } @@ -49,5 +120,76 @@ func (r *RateLimitReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { func (r *RateLimitReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&limitadorv1alpha1.RateLimit{}). + WithEventFilter(r.updateLimitPredicate()). Complete(r) } + +// This should be temporary. This is not how a filter should be used. However, +// with the current Limitador API, when updating a limit, we need both the +// current and the previous version. After updating the Limitador API to work +// with IDs, this won't be needed. +func (r *RateLimitReconciler) updateLimitPredicate() predicate.Predicate { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldVersion := e.ObjectOld.(*limitadorv1alpha1.RateLimit) + newVersion := e.ObjectNew.(*limitadorv1alpha1.RateLimit) + + if oldVersion.ObjectMeta.Generation == newVersion.ObjectMeta.Generation { + return false + } + + // The namespace should be the same in the old and the new version, + // so we can use either. + limitadorUrl, err := r.limitadorDiscovery.URL(newVersion.Namespace) + if err != nil { + return false + } + + limitadorClient := limitador.NewClient(*limitadorUrl) + + // Try to create the new version even if the old one can't be + // deleted. This might leave in Limitador limits that should no + // longer be there. As this function should only be temporary this + // should be fine for a first version of the controller. + _ = limitadorClient.DeleteLimit(&oldVersion.Spec) + + return true + }, + } +} + +func (r *RateLimitReconciler) createLimitInLimitador(limit *limitadorv1alpha1.RateLimit) error { + limitadorUrl, err := r.limitadorDiscovery.URL(limit.Namespace) + if err != nil { + return err + } + + limitadorClient := limitador.NewClient(*limitadorUrl) + return limitadorClient.CreateLimit(&limit.Spec) +} + +func (r *RateLimitReconciler) ensureFinalizerIsAdded(limit *limitadorv1alpha1.RateLimit, reqLogger logr.Logger) error { + numberOfFinalizers := len(limit.GetFinalizers()) + controllerutil.AddFinalizer(limit, rateLimitFinalizer) + if numberOfFinalizers == len(limit.GetFinalizers()) { + // The finalizer was already there, no need to update + return nil + } + + if err := r.Update(context.TODO(), limit); err != nil { + reqLogger.Error(err, "Failed to update the rate limit with finalizer") + return err + } + + return nil +} + +func (r *RateLimitReconciler) finalizeRateLimit(rateLimit *limitadorv1alpha1.RateLimit) error { + limitadorUrl, err := r.limitadorDiscovery.URL(rateLimit.Namespace) + if err != nil { + return err + } + + limitadorClient := limitador.NewClient(*limitadorUrl) + return limitadorClient.DeleteLimit(&rateLimit.Spec) +} diff --git a/controllers/ratelimit_controller_test.go b/controllers/ratelimit_controller_test.go new file mode 100644 index 00000000..b50c26d7 --- /dev/null +++ b/controllers/ratelimit_controller_test.go @@ -0,0 +1,169 @@ +package controllers + +import ( + "context" + limitadorv1alpha1 "github.com/3scale/limitador-operator/api/v1alpha1" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/ghttp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/uuid" + "sigs.k8s.io/controller-runtime/pkg/client" + "sync" + "time" +) + +var _ = Describe("RateLimit controller", func() { + const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 + ) + + // Used to generate a different limit on every test so they don't collide. + var newRateLimit = func() limitadorv1alpha1.RateLimit { + // The name can't start with a number. + name := "a" + string(uuid.NewUUID()) + + return limitadorv1alpha1.RateLimit{ + TypeMeta: metav1.TypeMeta{ + Kind: "RateLimit", + APIVersion: "limitador.3scale.net/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: limitadorv1alpha1.RateLimitSpec{ + Conditions: []string{"req.method == GET"}, + MaxValue: 10, + Namespace: "test-namespace", + Seconds: 60, + Variables: []string{"user_id"}, + }, + } + } + + // The next couple of functions are useful to verify that an HTTP request is + // made after a call to the kubernetesClient. + // The functions are wrappers for k8sClient.Create and k8sClient.Delete, so + // the signature is the same. + // We know that after creating, deleting, etc. a RateLimit CR, an HTTP + // request is made to create, delete, etc. the limit in Limitador. These + // functions are useful for waiting until the state is synchronized. + + // Wraps a function with the same signature as k8sClient.Create and waits + // for an HTTP request. + var runCreateAndWaitHTTPReq = func(f func(ctx context.Context, + object runtime.Object, + opts ...client.CreateOption, + ) error) func(ctx context.Context, object runtime.Object, opts ...client.CreateOption) error { + return func(ctx context.Context, object runtime.Object, opts ...client.CreateOption) error { + reqsAtStart := len(mockedHTTPServer.ReceivedRequests()) + + err := f(ctx, object, opts...) + if err != nil { + return err + } + + Eventually(func() bool { + return len(mockedHTTPServer.ReceivedRequests()) > reqsAtStart + }, timeout, interval).Should(BeTrue()) + + return nil + } + } + + // Wraps a function with the same signature as k8sClient.Delete and waits + // for an HTTP request. + var runDeleteAndWaitHTTPReq = func(f func(ctx context.Context, + object runtime.Object, + opts ...client.DeleteOption, + ) error) func(ctx context.Context, object runtime.Object, opts ...client.DeleteOption) error { + return func(ctx context.Context, object runtime.Object, opts ...client.DeleteOption) error { + reqsAtStart := len(mockedHTTPServer.ReceivedRequests()) + + err := f(ctx, object, opts...) + if err != nil { + return err + } + + Eventually(func() bool { + return len(mockedHTTPServer.ReceivedRequests()) > reqsAtStart + }, timeout, interval).Should(BeTrue()) + + return nil + } + } + + var addHandlerForLimitCreation = func(limitSpecJson string) { + mockedHTTPServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/limits"), + ghttp.VerifyJSON(limitSpecJson), + ), + ) + } + + var addHandlerForLimitDeletion = func(limitSpecJson string) { + mockedHTTPServer.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest("DELETE", "/limits"), + ghttp.VerifyJSON(limitSpecJson), + ), + ) + } + + // These tests make HTTP requests to the same mocked server. Running them in + // parallel makes it difficult to reason about them. + var sequentialTestLock sync.Mutex + + BeforeEach(func() { + sequentialTestLock.Lock() + defer sequentialTestLock.Unlock() + mockedHTTPServer.Reset() + }) + + Context("Creating a new RateLimit object", func() { + testLimit := newRateLimit() + testLimitSpecJson, _ := json.Marshal(testLimit.Spec) + + BeforeEach(func() { + addHandlerForLimitCreation(string(testLimitSpecJson)) + }) + + AfterEach(func() { + Expect(runDeleteAndWaitHTTPReq(k8sClient.Delete)( + context.TODO(), &testLimit, + )).Should(Succeed()) + }) + + It("Should create a limit in Limitador", func() { + Expect(runCreateAndWaitHTTPReq(k8sClient.Create)( + context.TODO(), &testLimit, + )).Should(Succeed()) + }) + }) + + Context("Deleting a RateLimit object", func() { + testLimit := newRateLimit() + testLimitSpecJson, _ := json.Marshal(testLimit.Spec) + + BeforeEach(func() { + addHandlerForLimitCreation(string(testLimitSpecJson)) + + Expect(runCreateAndWaitHTTPReq(k8sClient.Create)( + context.TODO(), &testLimit, + )).Should(Succeed()) + + addHandlerForLimitDeletion(string(testLimitSpecJson)) + }) + + It("Should delete the limit in Limitador", func() { + Expect(runDeleteAndWaitHTTPReq(k8sClient.Delete)( + context.TODO(), &testLimit, + )).Should(Succeed()) + }) + }) +}) diff --git a/controllers/suite_test.go b/controllers/suite_test.go index 7e42e414..9ed1d7a5 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -17,26 +17,20 @@ limitations under the License. package controllers import ( - "context" - appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "path/filepath" - ctrl "sigs.k8s.io/controller-runtime" - "testing" - "time" - . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + "github.com/onsi/gomega/ghttp" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + "net/url" + "path/filepath" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/envtest/printer" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "testing" limitadorv1alpha1 "github.com/3scale/limitador-operator/api/v1alpha1" // +kubebuilder:scaffold:imports @@ -48,6 +42,7 @@ import ( var cfg *rest.Config var k8sClient client.Client var testEnv *envtest.Environment +var mockedHTTPServer *ghttp.Server func TestAPIs(t *testing.T) { RegisterFailHandler(Fail) @@ -57,6 +52,15 @@ func TestAPIs(t *testing.T) { []Reporter{printer.NewlineReporter{}}) } +// In the tests, this just points to our mocked HTTP server +type TestLimitadorServiceDiscovery struct { + url url.URL +} + +func (sd *TestLimitadorServiceDiscovery) URL(_ string) (*url.URL, error) { + return &sd.url, nil +} + var _ = BeforeSuite(func(done Done) { logf.SetLogger(zap.LoggerTo(GinkgoWriter, true)) @@ -90,6 +94,21 @@ var _ = BeforeSuite(func(done Done) { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + mockedHTTPServer = ghttp.NewServer() + mockedHTTPServerURL, err := url.Parse(mockedHTTPServer.URL()) + Expect(err).ToNot(HaveOccurred()) + + // Set this to true so we don't have to specify all the requests, including + // the ones for example done for cleanup in AfterEach() functions. + mockedHTTPServer.SetAllowUnhandledRequests(true) + + err = (&RateLimitReconciler{ + Client: k8sManager.GetClient(), + Log: ctrl.Log.WithName("limitador"), + limitadorDiscovery: &TestLimitadorServiceDiscovery{url: *mockedHTTPServerURL}, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + go func() { err = k8sManager.Start(ctrl.SetupSignalHandler()) Expect(err).ToNot(HaveOccurred()) @@ -106,168 +125,3 @@ var _ = AfterSuite(func() { err := testEnv.Stop() Expect(err).ToNot(HaveOccurred()) }) - -var _ = Describe("Limitador controller", func() { - const ( - LimitadorName = "limitador-test" - LimitadorNamespace = "default" - LimitadorReplicas = 2 - LimitadorImage = "quay.io/3scale/limitador" - LimitadorVersion = "0.3.0" - - timeout = time.Second * 10 - interval = time.Millisecond * 250 - ) - - replicas := LimitadorReplicas - version := LimitadorVersion - limitador := limitadorv1alpha1.Limitador{ - TypeMeta: metav1.TypeMeta{ - Kind: "Limitador", - APIVersion: "limitador.3scale.net/v1alpha1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: LimitadorName, - Namespace: LimitadorNamespace, - }, - Spec: limitadorv1alpha1.LimitadorSpec{ - Replicas: &replicas, - Version: &version, - }, - } - - Context("Creating a new Limitador object", func() { - BeforeEach(func() { - err := k8sClient.Delete(context.TODO(), limitador.DeepCopy()) - Expect(err == nil || errors.IsNotFound(err)) - - Expect(k8sClient.Create(context.TODO(), limitador.DeepCopy())).Should(Succeed()) - }) - - It("Should create a new deployment with the right number of replicas and version", func() { - createdLimitadorDeployment := appsv1.Deployment{} - Eventually(func() bool { - err := k8sClient.Get( - context.TODO(), - types.NamespacedName{ - Namespace: LimitadorNamespace, - Name: LimitadorName, - }, - &createdLimitadorDeployment) - - return err == nil - }, timeout, interval).Should(BeTrue()) - - Expect(*createdLimitadorDeployment.Spec.Replicas).Should( - Equal((int32)(LimitadorReplicas)), - ) - Expect(createdLimitadorDeployment.Spec.Template.Spec.Containers[0].Image).Should( - Equal(LimitadorImage + ":" + LimitadorVersion), - ) - }) - - It("Should create a Limitador service", func() { - createdLimitadorService := v1.Service{} - Eventually(func() bool { - err := k8sClient.Get( - context.TODO(), - types.NamespacedName{ - Namespace: "default", // Hardcoded for now - Name: "limitador", // Hardcoded for now - }, - &createdLimitadorService) - - return err == nil - }, timeout, interval).Should(BeTrue()) - }) - }) - - Context("Deleting a Limitador object", func() { - BeforeEach(func() { - err := k8sClient.Create(context.TODO(), limitador.DeepCopy()) - Expect(err == nil || errors.IsAlreadyExists(err)) - - Expect(k8sClient.Delete(context.TODO(), limitador.DeepCopy())).Should(Succeed()) - }) - - It("Should delete the limitador deployment", func() { - createdLimitadorDeployment := appsv1.Deployment{} - Eventually(func() bool { - err := k8sClient.Get( - context.TODO(), - types.NamespacedName{ - Namespace: LimitadorNamespace, - Name: LimitadorName, - }, - &createdLimitadorDeployment) - - return errors.IsNotFound(err) - }, timeout, interval).Should(BeTrue()) - }) - - It("Should delete the limitador service", func() { - createdLimitadorService := v1.Service{} - Eventually(func() bool { - err := k8sClient.Get( - context.TODO(), - types.NamespacedName{ - Namespace: "default", // Hardcoded for now - Name: "limitador", // Hardcoded for now - }, - &createdLimitadorService) - - return errors.IsNotFound(err) - }, timeout, interval).Should(BeTrue()) - }) - }) - - Context("Updating a limitador object", func() { - BeforeEach(func() { - err := k8sClient.Delete(context.TODO(), limitador.DeepCopy()) - Expect(err == nil || errors.IsNotFound(err)) - - Expect(k8sClient.Create(context.TODO(), limitador.DeepCopy())).Should(Succeed()) - }) - - It("Should modify the limitador deployment", func() { - updatedLimitador := limitadorv1alpha1.Limitador{} - Eventually(func() bool { - err := k8sClient.Get( - context.TODO(), - types.NamespacedName{ - Namespace: LimitadorNamespace, - Name: LimitadorName, - }, - &updatedLimitador) - - return err == nil - }, timeout, interval).Should(BeTrue()) - - replicas = LimitadorReplicas + 1 - updatedLimitador.Spec.Replicas = &replicas - version = "latest" - updatedLimitador.Spec.Version = &version - - Expect(k8sClient.Update(context.TODO(), &updatedLimitador)).Should(Succeed()) - updatedLimitadorDeployment := appsv1.Deployment{} - Eventually(func() bool { - err := k8sClient.Get( - context.TODO(), - types.NamespacedName{ - Namespace: LimitadorNamespace, - Name: LimitadorName, - }, - &updatedLimitadorDeployment) - - if err != nil { - return false - } - - correctReplicas := *updatedLimitadorDeployment.Spec.Replicas == LimitadorReplicas+1 - correctImage := updatedLimitadorDeployment.Spec.Template.Spec.Containers[0].Image == LimitadorImage+":latest" - - return correctReplicas && correctImage - }, timeout, interval).Should(BeTrue()) - }) - }) -}) diff --git a/go.mod b/go.mod index ad4d1622..aa634664 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-logr/logr v0.1.0 github.com/onsi/ginkgo v1.12.1 github.com/onsi/gomega v1.10.1 + github.com/stretchr/testify v1.4.0 k8s.io/api v0.18.6 k8s.io/apimachinery v0.18.6 k8s.io/client-go v0.18.6 diff --git a/main.go b/main.go index c5f0978e..4b17e504 100644 --- a/main.go +++ b/main.go @@ -67,14 +67,16 @@ func main() { os.Exit(1) } - if err = (&controllers.RateLimitReconciler{ - Client: mgr.GetClient(), - Log: ctrl.Log.WithName("controllers").WithName("RateLimit"), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { + rateLimitReconciler := controllers.NewRateLimitReconciler( + mgr.GetClient(), + ctrl.Log.WithName("controllers").WithName("RateLimit"), + mgr.GetScheme(), + ) + if err = rateLimitReconciler.SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "RateLimit") os.Exit(1) } + if err = (&controllers.LimitadorReconciler{ Client: mgr.GetClient(), Log: ctrl.Log.WithName("controllers").WithName("Limitador"), diff --git a/pkg/helpers/helpers.go b/pkg/helpers/helpers.go new file mode 100644 index 00000000..9fdb8aa6 --- /dev/null +++ b/pkg/helpers/helpers.go @@ -0,0 +1,10 @@ +package helpers + +func Contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} diff --git a/pkg/limitador/client.go b/pkg/limitador/client.go new file mode 100644 index 00000000..9e00feec --- /dev/null +++ b/pkg/limitador/client.go @@ -0,0 +1,56 @@ +package limitador + +import ( + "bytes" + "fmt" + limitadorv1alpha1 "github.com/3scale/limitador-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/util/json" + "net/http" + "net/url" +) + +type Client struct { + url url.URL +} + +func NewClient(url url.URL) Client { + return Client{url: url} +} + +func (client *Client) CreateLimit(rateLimitSpec *limitadorv1alpha1.RateLimitSpec) error { + jsonLimit, err := json.Marshal(rateLimitSpec) + if err != nil { + return err + } + + _, err = http.Post( + fmt.Sprintf("%s/limits", client.url.String()), + "application/json", + bytes.NewBuffer(jsonLimit), + ) + + return err +} + +func (client *Client) DeleteLimit(rateLimitSpec *limitadorv1alpha1.RateLimitSpec) error { + jsonLimit, err := json.Marshal(rateLimitSpec) + if err != nil { + return err + } + + req, err := http.NewRequest( + "DELETE", + fmt.Sprintf("%s/limits", client.url.String()), + bytes.NewBuffer(jsonLimit), + ) + if err != nil { + return err + } + + req.Header.Add("Content-Type", "application/json") + + httpClient := &http.Client{} + _, err = httpClient.Do(req) + + return err +} diff --git a/pkg/limitador/client_test.go b/pkg/limitador/client_test.go new file mode 100644 index 00000000..8e4c5718 --- /dev/null +++ b/pkg/limitador/client_test.go @@ -0,0 +1,70 @@ +package limitador + +import ( + limitadorv1alpha1 "github.com/3scale/limitador-operator/api/v1alpha1" + "github.com/stretchr/testify/assert" + "io/ioutil" + "k8s.io/apimachinery/pkg/util/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestCreateLimit(t *testing.T) { + rateLimitSpec := exampleRateLimitSpec() + rateLimitSpecJson, err := json.Marshal(rateLimitSpec) + assert.NoError(t, err) + + testServerUrl, closeServerFunc := newTestServer(t, "POST", "/limits", string(rateLimitSpecJson)) + defer closeServerFunc() + + limitadorClient := NewClient(*testServerUrl) + err = limitadorClient.CreateLimit(rateLimitSpec) + + assert.NoError(t, err) +} + +func TestDeleteLimit(t *testing.T) { + rateLimitSpec := exampleRateLimitSpec() + rateLimitSpecJson, err := json.Marshal(rateLimitSpec) + assert.NoError(t, err) + + testServerUrl, closeServerFunc := newTestServer(t, "DELETE", "/limits", string(rateLimitSpecJson)) + defer closeServerFunc() + + limitadorClient := NewClient(*testServerUrl) + err = limitadorClient.DeleteLimit(rateLimitSpec) + + assert.NoError(t, err) +} + +// Creates a test server that checks the given HTTP request fields +func newTestServer(t *testing.T, expectedMethod string, expectedPath string, expectedBody string) (*url.URL, func()) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, expectedMethod, r.Method) + + assert.Equal(t, expectedPath, r.URL.Path) + + body, err := ioutil.ReadAll(r.Body) + assert.NoError(t, err) + err = r.Body.Close() + assert.NoError(t, err) + assert.Equal(t, expectedBody, string(body)) + })) + + serverUrl, err := url.Parse(testServer.URL) + assert.Nil(t, err) + + return serverUrl, testServer.Close +} + +func exampleRateLimitSpec() *limitadorv1alpha1.RateLimitSpec { + return &limitadorv1alpha1.RateLimitSpec{ + Conditions: []string{"req.method == GET"}, + MaxValue: 10, + Namespace: "test-namespace", + Seconds: 60, + Variables: []string{"user_id"}, + } +} diff --git a/pkg/limitador/k8s_objects.go b/pkg/limitador/k8s_objects.go index 2529278d..8544159d 100644 --- a/pkg/limitador/k8s_objects.go +++ b/pkg/limitador/k8s_objects.go @@ -15,6 +15,8 @@ const ( ServiceNamespace = "default" Image = "quay.io/3scale/limitador" StatusEndpoint = "/status" + ServiceHTTPPort = 8080 + ServiceGRPCPort = 8081 ) func LimitadorService() *v1.Service { @@ -33,13 +35,13 @@ func LimitadorService() *v1.Service { { Name: "http", Protocol: v1.ProtocolTCP, - Port: 8080, + Port: ServiceHTTPPort, TargetPort: intstr.FromString("http"), }, { Name: "grpc", Protocol: v1.ProtocolTCP, - Port: 8081, + Port: ServiceGRPCPort, TargetPort: intstr.FromString("grpc"), }, }, @@ -88,12 +90,12 @@ func LimitadorDeployment(limitador *limitadorv1alpha1.Limitador) *appsv1.Deploym Ports: []v1.ContainerPort{ { Name: "http", - ContainerPort: 8080, + ContainerPort: ServiceHTTPPort, Protocol: v1.ProtocolTCP, }, { Name: "grpc", - ContainerPort: 8081, + ContainerPort: ServiceGRPCPort, Protocol: v1.ProtocolTCP, }, }, @@ -107,7 +109,7 @@ func LimitadorDeployment(limitador *limitadorv1alpha1.Limitador) *appsv1.Deploym Handler: v1.Handler{ HTTPGet: &v1.HTTPGetAction{ Path: StatusEndpoint, - Port: intstr.FromInt(8080), + Port: intstr.FromInt(ServiceHTTPPort), Scheme: v1.URISchemeHTTP, }, }, @@ -121,7 +123,7 @@ func LimitadorDeployment(limitador *limitadorv1alpha1.Limitador) *appsv1.Deploym Handler: v1.Handler{ HTTPGet: &v1.HTTPGetAction{ Path: StatusEndpoint, - Port: intstr.FromInt(8080), + Port: intstr.FromInt(ServiceHTTPPort), Scheme: v1.URISchemeHTTP, }, },