diff --git a/.golangci.yml b/.golangci.yml index e5bebef..9e276cb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -12,7 +12,7 @@ issues: - path: "api/*" linters: - lll - - path: "internal/*" + - path: "controller/*" linters: - dupl - lll diff --git a/Dockerfile b/Dockerfile index e129d83..52ff732 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,7 @@ RUN go mod download # Copy the go source COPY cmd/main.go cmd/main.go -COPY internal/controller/ internal/controller/ -COPY pkg/ pkg/ +COPY controller/ controller/ # Build # the GOARCH has not a default value to allow the binary be built according to the host where the command diff --git a/cmd/main.go b/cmd/main.go index 9927107..6048071 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,7 +18,9 @@ package main import ( "crypto/tls" + "errors" "flag" + "fmt" "os" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) @@ -26,6 +28,7 @@ import ( _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" @@ -35,14 +38,15 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" - "github.com/kuoss/ingress-annotator/internal/controller" - "github.com/kuoss/ingress-annotator/pkg/rulesstore" + "github.com/kuoss/ingress-annotator/controller" + "github.com/kuoss/ingress-annotator/controller/rulesstore" // +kubebuilder:scaffold:imports ) var ( - scheme = runtime.NewScheme() - setupLog = ctrl.Log.WithName("setup") + configMapName = "ingress-annotator" + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") ) func init() { @@ -52,6 +56,13 @@ func init() { } func main() { + if err := run(); err != nil { + setupLog.Error(err, "unable to run the manager") + os.Exit(1) + } +} + +func run() error { var metricsAddr string var enableLeaderElection bool var probeAddr string @@ -82,13 +93,12 @@ func main() { // Rapid Reset CVEs. For more information see: // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 // - https://github.com/advisories/GHSA-4374-p667-p6c8 - disableHTTP2 := func(c *tls.Config) { - setupLog.Info("disabling http/2") - c.NextProtos = []string{"http/1.1"} - } if !enableHTTP2 { - tlsOpts = append(tlsOpts, disableHTTP2) + tlsOpts = append(tlsOpts, func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + }) } webhookServer := webhook.NewServer(webhook.Options{ @@ -139,41 +149,52 @@ func main() { // LeaderElectionReleaseOnCancel: true, }) if err != nil { - setupLog.Error(err, "unable to start manager") + return errors.New("unable to start manager") + } + + ns, exists := os.LookupEnv("POD_NAMESPACE") + if !exists || ns == "" { + return errors.New("POD_NAMESPACE environment variable is not set or is empty") + } + + nn := types.NamespacedName{ + Namespace: ns, + Name: configMapName, + } + rulesStore, err := rulesstore.New(mgr.GetClient(), nn) + if err != nil { + setupLog.Error(err, "unable to start rules store") os.Exit(1) } - rulesStore := rulesstore.New() if err = (&controller.ConfigMapReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), + ConfigNN: nn, RulesStore: rulesStore, }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "ConfigMap") - os.Exit(1) + return fmt.Errorf("unable to create ConfigMapReconciler: %w", err) } if err = (&controller.IngressReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), RulesStore: rulesStore, }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Ingress") - os.Exit(1) + return fmt.Errorf("unable to create IngressReconciler: %w", err) } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { - setupLog.Error(err, "unable to set up health check") - os.Exit(1) + return fmt.Errorf("unable to set up health check: %w", err) } if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { - setupLog.Error(err, "unable to set up ready check") - os.Exit(1) + return fmt.Errorf("unable to set up ready check: %w", err) } setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - setupLog.Error(err, "problem running manager") - os.Exit(1) + return fmt.Errorf("problem running manager: %w", err) } + + return nil } diff --git a/internal/controller/configmap_controller.go b/controller/configmap_controller.go similarity index 59% rename from internal/controller/configmap_controller.go rename to controller/configmap_controller.go index 1f370bb..a4fabd3 100644 --- a/internal/controller/configmap_controller.go +++ b/controller/configmap_controller.go @@ -18,9 +18,7 @@ package controller import ( "context" - "errors" "fmt" - "os" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" @@ -29,14 +27,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "github.com/kuoss/ingress-annotator/pkg/rulesstore" + "github.com/kuoss/ingress-annotator/controller/rulesstore" ) // ConfigMapReconciler reconciles a ConfigMap object type ConfigMapReconciler struct { client.Client Scheme *runtime.Scheme - ConfigMeta types.NamespacedName + ConfigNN types.NamespacedName RulesStore rulesstore.IRulesStore } @@ -46,17 +44,6 @@ type ConfigMapReconciler struct { // SetupWithManager sets up the controller with the Manager. func (r *ConfigMapReconciler) SetupWithManager(mgr ctrl.Manager) error { - ns, exists := os.LookupEnv("POD_NAMESPACE") - if !exists || ns == "" { - return errors.New("POD_NAMESPACE environment variable is not set or is empty") - } - r.ConfigMeta = types.NamespacedName{ - Namespace: ns, - Name: "ingress-annotator-rules", - } - if err := r.updateRulesWithConfigMap(context.Background()); err != nil { - return fmt.Errorf("updateRulesWithConfigMap err: %w", err) - } return ctrl.NewControllerManagedBy(mgr). For(&corev1.ConfigMap{}). Complete(r) @@ -72,31 +59,14 @@ func (r *ConfigMapReconciler) SetupWithManager(mgr ctrl.Manager) error { // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.18.4/pkg/reconcile func (r *ConfigMapReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - if req.Namespace == r.ConfigMeta.Namespace && req.Name == r.ConfigMeta.Name { - return r.reconcileNormal(ctx, req) - } - return ctrl.Result{}, nil -} + if req.Namespace == r.ConfigNN.Namespace && req.Name == r.ConfigNN.Name { + logger := log.FromContext(ctx).WithValues("kind", "configmap", "namespace", req.Namespace, "name", req.Name) -func (r *ConfigMapReconciler) reconcileNormal(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := log.FromContext(ctx).WithValues("kind", "configmap", "namespace", req.Namespace, "name", req.Name).WithCallDepth(1) - - logger.Info("Reconciling ConfigMap") - if err := r.updateRulesWithConfigMap(context.Background()); err != nil { - return ctrl.Result{}, fmt.Errorf("updateRulesWithConfigMap err: %w", err) + logger.Info("Reconciling ConfigMap") + if err := r.RulesStore.UpdateRules(); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to update rules in rules store: %w", err) + } + logger.Info("Successfully reconciled ConfigMap") } - - logger.Info("Successfully reconciled ConfigMap") return ctrl.Result{}, nil } - -func (r *ConfigMapReconciler) updateRulesWithConfigMap(ctx context.Context) error { - var cm corev1.ConfigMap - if err := r.Get(ctx, r.ConfigMeta, &cm); err != nil { - return fmt.Errorf("getConfigMap err: %w", err) - } - if err := r.RulesStore.UpdateRules(&cm); err != nil { - return fmt.Errorf("failed to update data in rules store: %w", err) - } - return nil -} diff --git a/controller/configmap_controller_test.go b/controller/configmap_controller_test.go new file mode 100644 index 0000000..1b1faf0 --- /dev/null +++ b/controller/configmap_controller_test.go @@ -0,0 +1,196 @@ +package controller + +import ( + "context" + "testing" + + "github.com/jmnote/tester" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/kuoss/ingress-annotator/controller/fakeclient" + "github.com/kuoss/ingress-annotator/controller/rulesstore" +) + +func TestConfigMapReconciler_SetupWithManager(t *testing.T) { + testCases := []struct { + name string + namespaceEnv string + objects []client.Object + wantError string + }{ + { + name: "successful setup 1", + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: ctrl.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + }, + }, + wantError: "", + }, + { + name: "successful setup 2", + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: ctrl.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + Data: map[string]string{ + "rule1": "annotations:\n key1: value1\nnamespace: test-namespace\ningress: test-ingress", + }, + }, + }, + wantError: "", + }, + } + + for i, tc := range testCases { + t.Run(tester.Name(i, tc.name), func(t *testing.T) { + nn := types.NamespacedName{Namespace: "default", Name: "ingress-annotator"} + client := fakeclient.NewClient(nil, tc.objects...) + store, err := rulesstore.New(client, nn) + assert.NoError(t, err) + reconciler := &ConfigMapReconciler{ + Client: client, + Scheme: fakeclient.NewScheme(), + RulesStore: store, + } + err = reconciler.SetupWithManager(fakeclient.NewManager()) + if tc.wantError == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.wantError) + } + }) + } +} +func TestConfigMapReconciler_Reconcile(t *testing.T) { + testCases := []struct { + name string + configNN types.NamespacedName + objects []client.Object + requestMeta types.NamespacedName + wantResult reconcile.Result + wantError string + }{ + { + name: "successful reconciliation", + configNN: types.NamespacedName{Namespace: "default", Name: "ingress-annotator"}, + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: ctrl.ObjectMeta{Namespace: "default", Name: "ingress-annotator"}, + Data: map[string]string{"rule1": "annotations:\n key: value\nnamespace: default\ningress: my-ingress"}, + }, + }, + requestMeta: types.NamespacedName{Namespace: "default", Name: "ingress-annotator"}, + wantResult: reconcile.Result{}, + wantError: "", + }, + { + name: "successful reconciliation for other cm", + configNN: types.NamespacedName{Namespace: "default", Name: "ingress-annotator"}, + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: ctrl.ObjectMeta{Namespace: "default", Name: "ingress-annotator"}, + Data: map[string]string{"rule1": "annotations:\n key: value\nnamespace: default\ningress: my-ingress"}, + }, + }, + requestMeta: types.NamespacedName{Namespace: "default", Name: "xxx"}, + wantResult: reconcile.Result{}, + wantError: "", + }, + } + + for i, tc := range testCases { + t.Run(tester.Name(i, tc.name), func(t *testing.T) { + ctx := context.Background() + nn := types.NamespacedName{Namespace: "default", Name: "ingress-annotator"} + client := fakeclient.NewClient(nil, tc.objects...) + store, err := rulesstore.New(client, nn) + assert.NoError(t, err) + reconciler := &ConfigMapReconciler{ + Client: client, + Scheme: runtime.NewScheme(), + ConfigNN: tc.configNN, + RulesStore: store, + } + + req := ctrl.Request{NamespacedName: tc.requestMeta} + result, err := reconciler.Reconcile(ctx, req) + if tc.wantError == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.wantError) + } + assert.Equal(t, tc.wantResult, result) + }) + } +} + +// func TestConfigMapReconciler_updateRulesWithConfigMap(t *testing.T) { +// testCases := []struct { +// name string +// configMap *corev1.ConfigMap +// expectedError bool +// }{ +// { +// name: "valid config map", +// configMap: &corev1.ConfigMap{ +// ObjectMeta: ctrl.ObjectMeta{ +// Name: "ingress-annotator", +// Namespace: "default", +// }, +// Data: map[string]string{ +// "rule1": "annotations:\n key: value\nnamespace: default\ningress: my-ingress", +// }, +// }, +// expectedError: false, +// }, +// { +// name: "invalid config map data", +// configMap: &corev1.ConfigMap{ +// ObjectMeta: ctrl.ObjectMeta{ +// Name: "ingress-annotator", +// Namespace: "default", +// }, +// Data: map[string]string{ +// "rule1": "invalid yaml", +// }, +// }, +// expectedError: true, +// }, +// } + +// for i, tc := range testCases { +// t.Run(tester.Name(i, tc.name), func(t *testing.T) { +// client := newFakeClient(nil, tc.configMap) +// store, err := rulesstore.New() +// assert.NoError(t, err) +// reconciler := &ConfigMapReconciler{ +// Client: client, +// Scheme: runtime.NewScheme(), +// ConfigMeta: types.NamespacedName{Namespace: "default", Name: "ingress-annotator"}, +// RulesStore: store, +// } + +// err = reconciler.updateRulesWithConfigMap(context.Background()) +// if tc.expectedError { +// assert.Error(t, err) +// } else { +// assert.NoError(t, err) +// rules := store.GetRules() +// assert.NotNil(t, rules) +// assert.Contains(t, *rules, "rule1") +// } +// }) +// } +// } diff --git a/controller/fakeclient/fakeclient.go b/controller/fakeclient/fakeclient.go new file mode 100644 index 0000000..c0c73db --- /dev/null +++ b/controller/fakeclient/fakeclient.go @@ -0,0 +1,60 @@ +package fakeclient + +import ( + "context" + "errors" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +func NewScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = networkingv1.AddToScheme(scheme) + return scheme +} + +type ClientOpts struct { + GetError bool + UpdateError bool +} + +func NewClient(opts *ClientOpts, objs ...client.Object) client.Client { + if opts == nil { + opts = &ClientOpts{} + } + + interceptorFuncs := interceptor.Funcs{} + if opts.GetError { + interceptorFuncs.Get = func(ctx context.Context, client client.WithWatch, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + return errors.New("mocked Get error") + } + } + if opts.UpdateError { + interceptorFuncs.Update = func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.UpdateOption) error { + return errors.New("mocked Update error") + } + } + return fake.NewClientBuilder(). + WithScheme(NewScheme()). + WithInterceptorFuncs(interceptorFuncs). + WithObjects(objs...). + Build() +} + +func NewManager() manager.Manager { + mgr, err := ctrl.NewManager(&rest.Config{}, ctrl.Options{Scheme: NewScheme()}) + if err != nil { + panic(err) // test unreachable + } + return mgr +} diff --git a/controller/fakeclient/fakeclient_test.go b/controller/fakeclient/fakeclient_test.go new file mode 100644 index 0000000..5e9b34d --- /dev/null +++ b/controller/fakeclient/fakeclient_test.go @@ -0,0 +1,53 @@ +package fakeclient + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestNewClient_NilOpts(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + cl := NewClient(nil, pod) + gotPod := &corev1.Pod{} + err := cl.Get(context.TODO(), types.NamespacedName{Name: "test-pod", Namespace: "default"}, gotPod) + assert.NoError(t, err) + assert.Equal(t, pod, gotPod) + err = cl.Update(context.TODO(), pod) + assert.NoError(t, err) +} + +func TestNewClient_GetError(t *testing.T) { + opts := &ClientOpts{GetError: true} + cl := NewClient(opts) + pod := &corev1.Pod{} + err := cl.Get(context.TODO(), types.NamespacedName{Name: "test-pod", Namespace: "default"}, pod) + assert.EqualError(t, err, "mocked Get error") +} + +func TestNewClient_UpdateError(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + opts := &ClientOpts{UpdateError: true} + cl := NewClient(opts, pod) + err := cl.Update(context.TODO(), pod) + assert.EqualError(t, err, "mocked Update error") +} + +func TestNewManager(t *testing.T) { + got := NewManager() + assert.NotEmpty(t, got) +} diff --git a/internal/controller/ingress_controller.go b/controller/ingress_controller.go similarity index 92% rename from internal/controller/ingress_controller.go rename to controller/ingress_controller.go index c74f7c5..c87d5cf 100644 --- a/internal/controller/ingress_controller.go +++ b/controller/ingress_controller.go @@ -28,9 +28,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "github.com/go-logr/logr" - "github.com/kuoss/ingress-annotator/pkg/matcher" - "github.com/kuoss/ingress-annotator/pkg/model" - "github.com/kuoss/ingress-annotator/pkg/rulesstore" + "github.com/kuoss/ingress-annotator/controller/matcher" + "github.com/kuoss/ingress-annotator/controller/model" + "github.com/kuoss/ingress-annotator/controller/rulesstore" ) const ( @@ -72,7 +72,7 @@ func (r *IngressReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct ingressCtx := &IngressContext{ ctx: ctx, - logger: log.FromContext(ctx).WithValues("kind", "ingress", "namespace", ingress.Namespace, "name", ingress.Name).WithCallDepth(1), + logger: log.FromContext(ctx).WithValues("kind", "ingress", "namespace", ingress.Namespace, "name", ingress.Name), ingress: ingress, rules: r.RulesStore.GetRules(), } @@ -118,10 +118,14 @@ func (r *IngressReconciler) getNewManagedAnnotations(ctx *IngressContext) model. } // updateAnnotations applies the calculated annotations to the Ingress resource. -func (r *IngressReconciler) updateAnnotations(ingressCtx *IngressContext, annotationsToRemove, annotationsToApply, newManagedAnnotations model.Annotations) error { +func (r *IngressReconciler) updateAnnotations( + ingressCtx *IngressContext, + toRemove, annotationsToApply, + newManagedAnnotations model.Annotations, +) error { ingress := ingressCtx.ingress - for key := range annotationsToRemove { + for key := range toRemove { delete(ingress.Annotations, key) } diff --git a/internal/controller/ingress_controller_test.go b/controller/ingress_controller_test.go similarity index 92% rename from internal/controller/ingress_controller_test.go rename to controller/ingress_controller_test.go index 7ae9201..677439e 100644 --- a/internal/controller/ingress_controller_test.go +++ b/controller/ingress_controller_test.go @@ -8,9 +8,7 @@ import ( "github.com/go-logr/logr" "github.com/jmnote/tester" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -19,12 +17,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/log/zap" - "github.com/kuoss/ingress-annotator/pkg/model" + "github.com/kuoss/ingress-annotator/controller/fakeclient" + "github.com/kuoss/ingress-annotator/controller/model" + "github.com/kuoss/ingress-annotator/controller/rulesstore/mockrulesstore" ) // TestSetupWithManager tests the SetupWithManager method of IngressReconciler. func TestSetupWithManager(t *testing.T) { - mockRulesStore := new(MockRulesStore) + mockRulesStore := new(mockrulesstore.RulesStore) rules := &model.Rules{ "default/example-ingress": { Namespace: "default", @@ -36,39 +36,24 @@ func TestSetupWithManager(t *testing.T) { } mockRulesStore.On("GetRules").Return(rules) + client := fakeclient.NewClient(nil) reconciler := &IngressReconciler{ - Client: newFakeClient(), - Scheme: newScheme(), + Client: client, + Scheme: fakeclient.NewScheme(), RulesStore: mockRulesStore, } - err := reconciler.SetupWithManager(newFakeManager()) + err := reconciler.SetupWithManager(fakeclient.NewManager()) assert.NoError(t, err) } -// Mocked RulesStore for testing -type MockRulesStore struct { - mock.Mock - Rules *model.Rules -} - -func (m *MockRulesStore) GetRules() *model.Rules { - args := m.Called() - return args.Get(0).(*model.Rules) -} - -func (m *MockRulesStore) UpdateRules(cm *corev1.ConfigMap) error { - args := m.Called() - return args.Get(0).(error) -} - // TestReconcile tests the Reconcile method of IngressReconciler func TestReconcile(t *testing.T) { testCases := []struct { name string ingress *networkingv1.Ingress rules *model.Rules - badClient1 bool + clientOpts *fakeclient.ClientOpts wantApplied map[string]string wantRemoved []string wantError string @@ -201,7 +186,7 @@ func TestReconcile(t *testing.T) { }, wantApplied: map[string]string{"new-key": "new-value"}, wantRemoved: nil, - badClient1: true, + clientOpts: &fakeclient.ClientOpts{UpdateError: true}, wantError: "failed to update ingress annotations", }, } @@ -212,18 +197,15 @@ func TestReconcile(t *testing.T) { if tc.ingress != nil { objects = []client.Object{tc.ingress} } - client := newFakeClient(objects...) - if tc.badClient1 { - client = newBadClient1(objects...) - } + client := fakeclient.NewClient(tc.clientOpts, objects...) // Setup the IngressReconciler with a mock RulesStore - mockRulesStore := new(MockRulesStore) - mockRulesStore.On("GetRules").Return(tc.rules) + store := new(mockrulesstore.RulesStore) + store.On("GetRules").Return(tc.rules) reconciler := &IngressReconciler{ Client: client, - Scheme: newScheme(), - RulesStore: mockRulesStore, + Scheme: fakeclient.NewScheme(), + RulesStore: store, } // Run Reconcile @@ -360,8 +342,8 @@ func TestGetManagedAnnotations(t *testing.T) { } r := &IngressReconciler{ - Client: newFakeClient(&tc.ingress), - Scheme: newScheme(), + Client: fakeclient.NewClient(nil, &tc.ingress), + Scheme: fakeclient.NewScheme(), } got := r.getNewManagedAnnotations(ctx) @@ -379,7 +361,7 @@ func TestUpdateAnnotations(t *testing.T) { annotationsToApply map[string]string newManagedAnnotations map[string]string wantResult map[string]string - badClient1 bool + clientOpts *fakeclient.ClientOpts wantError string }{ { @@ -454,8 +436,8 @@ func TestUpdateAnnotations(t *testing.T) { annotationsToApply: map[string]string{}, newManagedAnnotations: map[string]string{}, wantResult: map[string]string{}, - badClient1: true, - wantError: "failed to update ingress: Update operation is disabled in this fake client", + clientOpts: &fakeclient.ClientOpts{UpdateError: true}, + wantError: "failed to update ingress: mocked Update error", }, } @@ -475,10 +457,7 @@ func TestUpdateAnnotations(t *testing.T) { } // Create a fake client and IngressContext - client := newFakeClient(ingress) - if tc.badClient1 { - client = newBadClient1(ingress) - } + client := fakeclient.NewClient(tc.clientOpts, ingress) logger := logr.Discard() // Using discard logger for testing diff --git a/pkg/matcher/matcher.go b/controller/matcher/matcher.go similarity index 100% rename from pkg/matcher/matcher.go rename to controller/matcher/matcher.go diff --git a/pkg/matcher/matcher_test.go b/controller/matcher/matcher_test.go similarity index 100% rename from pkg/matcher/matcher_test.go rename to controller/matcher/matcher_test.go diff --git a/pkg/model/model.go b/controller/model/model.go similarity index 100% rename from pkg/model/model.go rename to controller/model/model.go diff --git a/controller/rulesstore/mockrulesstore/mockrulesstore.go b/controller/rulesstore/mockrulesstore/mockrulesstore.go new file mode 100644 index 0000000..4e4abc1 --- /dev/null +++ b/controller/rulesstore/mockrulesstore/mockrulesstore.go @@ -0,0 +1,30 @@ +package mockrulesstore + +import ( + "github.com/stretchr/testify/mock" + + "github.com/kuoss/ingress-annotator/controller/model" + "github.com/kuoss/ingress-annotator/controller/rulesstore" +) + +type RulesStore struct { + mock.Mock + Rules *model.Rules +} + +func (m *RulesStore) GetRules() *model.Rules { + args := m.Called() + return args.Get(0).(*model.Rules) +} + +func (m *RulesStore) UpdateRules() error { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).(error) +} + +func New() rulesstore.IRulesStore { + return new(RulesStore) +} diff --git a/controller/rulesstore/mockrulesstore/mockrulesstore_test.go b/controller/rulesstore/mockrulesstore/mockrulesstore_test.go new file mode 100644 index 0000000..c8c3f3b --- /dev/null +++ b/controller/rulesstore/mockrulesstore/mockrulesstore_test.go @@ -0,0 +1,48 @@ +package mockrulesstore_test + +import ( + "errors" + "testing" + + "github.com/kuoss/ingress-annotator/controller/model" + "github.com/kuoss/ingress-annotator/controller/rulesstore/mockrulesstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGetRules(t *testing.T) { + sampleRules := &model.Rules{} + mockStore := new(mockrulesstore.RulesStore) + mockStore.On("GetRules").Return(sampleRules) + + result := mockStore.GetRules() + assert.Equal(t, sampleRules, result) +} + +func TestUpdateRulesSuccess(t *testing.T) { + mockStore := new(mockrulesstore.RulesStore) + mockStore.On("UpdateRules").Return(nil) + + err := mockStore.UpdateRules() + assert.NoError(t, err) +} + +func TestUpdateRulesError(t *testing.T) { + mockStore := new(mockrulesstore.RulesStore) + expectedError := errors.New("update failed") + mockStore.On("UpdateRules").Return(expectedError) + + err := mockStore.UpdateRules() + + assert.EqualError(t, err, "update failed") + mockStore.AssertExpectations(t) +} + +func TestNew(t *testing.T) { + want := &mockrulesstore.RulesStore{ + Mock: mock.Mock{}, + Rules: (*model.Rules)(nil), + } + got := mockrulesstore.New() + assert.Equal(t, want, got) +} diff --git a/pkg/rulesstore/rulesstore.go b/controller/rulesstore/rulesstore.go similarity index 64% rename from pkg/rulesstore/rulesstore.go rename to controller/rulesstore/rulesstore.go index 7942298..fa4cd99 100644 --- a/pkg/rulesstore/rulesstore.go +++ b/controller/rulesstore/rulesstore.go @@ -1,30 +1,43 @@ package rulesstore import ( + "context" "fmt" "regexp" + "strings" "sync" "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/kuoss/ingress-annotator/pkg/model" + "github.com/kuoss/ingress-annotator/controller/model" ) type IRulesStore interface { GetRules() *model.Rules - UpdateRules(cm *corev1.ConfigMap) error + UpdateRules() error } type RulesStore struct { + client client.Client + nn types.NamespacedName Rules *model.Rules rulesMutex *sync.Mutex } -func New() *RulesStore { - return &RulesStore{ +func New(c client.Client, nn types.NamespacedName) (*RulesStore, error) { + store := &RulesStore{ + client: c, + nn: nn, rulesMutex: &sync.Mutex{}, } + if err := store.UpdateRules(); err != nil { + return nil, fmt.Errorf("store.UpdateRules err: %w", err) + } + return store, nil } func (s *RulesStore) GetRules() *model.Rules { @@ -34,9 +47,21 @@ func (s *RulesStore) GetRules() *model.Rules { return s.Rules } -func (s *RulesStore) UpdateRules(cm *corev1.ConfigMap) error { +func (s *RulesStore) UpdateRules() error { + cm := &corev1.ConfigMap{} + err := s.client.Get(context.Background(), s.nn, cm) + if err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("ConfigMap %s not found", s.nn) + } + return err + } newRules := model.Rules{} for key, text := range cm.Data { + text = strings.TrimSpace(text) + if text == "" { + continue + } rule, err := getRuleValueFromText(text) if err != nil { return fmt.Errorf("invalid data in ConfigMap key %s: %w", key, err) diff --git a/controller/rulesstore/rulesstore_test.go b/controller/rulesstore/rulesstore_test.go new file mode 100644 index 0000000..02ca71c --- /dev/null +++ b/controller/rulesstore/rulesstore_test.go @@ -0,0 +1,465 @@ +package rulesstore + +import ( + "testing" + + "github.com/jmnote/tester" + "github.com/kuoss/ingress-annotator/controller/fakeclient" + "github.com/kuoss/ingress-annotator/controller/model" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestNew(t *testing.T) { + testCases := []struct { + objects []client.Object + clientOpts *fakeclient.ClientOpts + want *RulesStore + wantError string + }{ + { + objects: nil, + wantError: "store.UpdateRules err: ConfigMap default/ingress-annotator not found", + }, + { + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + }, + }, + want: &RulesStore{ + nn: types.NamespacedName{Namespace: "default", Name: "ingress-annotator"}, + Rules: &model.Rules{}, + }, + }, + { + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + }, + }, + clientOpts: &fakeclient.ClientOpts{GetError: true}, + wantError: "store.UpdateRules err: mocked Get error", + }, + { + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + Data: map[string]string{ + "rule1": "", + }, + }, + }, + want: &RulesStore{ + nn: types.NamespacedName{Namespace: "default", Name: "ingress-annotator"}, + Rules: &model.Rules{}, + }, + }, + { + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + Data: map[string]string{ + "rule1": `annotations: + key1: value1 +namespace: test-namespace +ingress: test-ingress`, + }, + }, + }, + want: &RulesStore{ + nn: types.NamespacedName{Namespace: "default", Name: "ingress-annotator"}, + Rules: &model.Rules{ + "rule1": model.Rule{ + Annotations: model.Annotations{"key1": "value1"}, + Namespace: "test-namespace", + Ingress: "test-ingress", + }}, + }, + }, + { + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + Data: map[string]string{ + "rule1": `annotations: + key1: value1 +namespace: test-namespace +ingress: test-ingress`, + }, + }, + }, + want: &RulesStore{ + nn: types.NamespacedName{Namespace: "default", Name: "ingress-annotator"}, + Rules: &model.Rules{ + "rule1": model.Rule{ + Annotations: model.Annotations{"key1": "value1"}, + Namespace: "test-namespace", + Ingress: "test-ingress", + }}, + }, + }, + { + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + Data: map[string]string{ + "rule1": `invalid_data`, + }, + }, + }, + wantError: "store.UpdateRules err: invalid data in ConfigMap key rule1: failed to unmarshal YAML: yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `invalid...` into model.Rule", + }, + { + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + Data: map[string]string{ + "rule1": `annotations: + key1: value1 +namespace: test-namespace +ingress: test-ingress`, + }, + }, + }, + want: &RulesStore{ + nn: types.NamespacedName{Namespace: "default", Name: "ingress-annotator"}, + Rules: &model.Rules{ + "rule1": model.Rule{ + Annotations: model.Annotations{"key1": "value1"}, + Namespace: "test-namespace", + Ingress: "test-ingress", + }}, + }, + }, + { + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + Data: map[string]string{ + "rule1": `invalid_data`, + }, + }, + }, + wantError: "store.UpdateRules err: invalid data in ConfigMap key rule1: failed to unmarshal YAML: yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `invalid...` into model.Rule", + }, + { + objects: []client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingress-annotator", + Namespace: "default", + }, + Data: map[string]string{ + "rule1": `namespace: invalid_namespace!`, + }, + }, + }, + wantError: "store.UpdateRules err: validateRule err: invalid namespace pattern: invalid_namespace!", + }, + } + for i, tc := range testCases { + t.Run(tester.Name(i, tc), func(t *testing.T) { + nn := types.NamespacedName{Namespace: "default", Name: "ingress-annotator"} + client := fakeclient.NewClient(tc.clientOpts, tc.objects...) + got, err := New(client, nn) + if tc.wantError == "" { + assert.NoError(t, err) + tc.want.client = got.client + tc.want.rulesMutex = got.rulesMutex + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.want.Rules, got.GetRules()) + } else { + assert.EqualError(t, err, tc.wantError) + assert.Equal(t, got, tc.want) + } + }) + } +} + +// func TestRulesStore(t *testing.T) { +// mockData := map[string]string{ +// "rule1": ` +// annotations: +// key1: value1 +// namespace: test-namespace +// ingress: test-ingress`, +// "rule2": ` +// annotations: +// key2: value2 +// namespace: another-namespace`, +// } +// cm := &corev1.ConfigMap{ +// Data: mockData, +// } + +// store := New() +// err := store.UpdateRules(cm) +// assert.NoError(t, err) + +// want := &model.Rules{ +// "rule1": { +// Annotations: model.Annotations{"key1": "value1"}, +// Namespace: "test-namespace", +// Ingress: "test-ingress", +// }, +// "rule2": { +// Annotations: model.Annotations{"key2": "value2"}, +// Namespace: "another-namespace", +// Ingress: "", +// }, +// } +// got := store.GetRules() +// assert.Equal(t, want, got) +// } + +// func TestGetRuleValueFromText(t *testing.T) { +// yamlText := ` +// annotations: +// key1: value1 +// namespace: test-namespace +// ingress: test-ingress` +// want := &model.Rule{ +// Annotations: model.Annotations{"key1": "value1"}, +// Namespace: "test-namespace", +// Ingress: "test-ingress", +// } + +// got, err := getRuleValueFromText(yamlText) +// assert.NoError(t, err) +// assert.Equal(t, want, got) +// } + +// func TestConcurrency(t *testing.T) { +// store := New() +// store.Rules = &model.Rules{ +// "initialRule": { +// Annotations: model.Annotations{"initialKey": "initialValue"}, +// Namespace: "initial-namespace", +// Ingress: "initial-ingress", +// }, +// } + +// var wg sync.WaitGroup + +// // Test concurrent access to GetRules and UpdateRules +// for i := 0; i < 100; i++ { +// wg.Add(1) +// go func() { +// defer wg.Done() +// store.GetRules() +// }() + +// wg.Add(1) +// go func(_ int) { +// defer wg.Done() +// mockData := map[string]string{ +// "rule": ` +// annotations: +// key: value +// namespace: namespace`, +// } +// cm := &corev1.ConfigMap{ +// Data: mockData, +// } +// _ = store.UpdateRules(cm) +// }(i) +// } + +// wg.Wait() +// } + +// func TestUpdateRules(t *testing.T) { +// testCases := []struct { +// name string +// configMapData map[string]string +// wantError string +// }{ +// { +// name: "Valid rule", +// configMapData: map[string]string{ +// "rule1": "namespace: test-namespace\ningress: test-ingress", +// }, +// wantError: "", +// }, +// { +// name: "Invalid namespace pattern", +// configMapData: map[string]string{ +// "rule1": "namespace: invalid_namespace!\ningress: test-ingress", +// }, +// wantError: "store.UpdateRules err: validateRule err: invalid namespace pattern: invalid_namespace!", +// }, +// { +// name: "Invalid ingress pattern", +// configMapData: map[string]string{ +// "rule1": "namespace: test-namespace\ningress: invalid_ingress!", +// }, +// wantError: "", +// }, +// { +// name: "Invalid YAML", +// configMapData: map[string]string{ +// "rule1": "namespace: test-namespace\ningress", +// }, +// wantError: "", +// }, +// { +// name: "Empty ConfigMap", +// configMapData: map[string]string{}, +// wantError: "", +// }, +// } + +// for i, tc := range testCases { +// t.Run(tester.Name(i, tc.name), func(t *testing.T) { +// t.Setenv("POD_NAMESPACE", "default") +// cm := &corev1.ConfigMap{ +// ObjectMeta: metav1.ObjectMeta{ +// Name: "ingress-annotator", +// Namespace: "default", +// }, +// Data: tc.configMapData, +// } +// client := testutil.NewFakeClient(nil, cm) +// store, err := New(client) +// assert.NoError(t, err) + +// err = store.UpdateRules() +// if tc.wantError == "" { +// assert.NoError(t, err) +// } else { +// assert.EqualError(t, err, tc.wantError) +// } +// }) +// } +// } + +func TestValidateRule(t *testing.T) { + testCases := []struct { + name string + rule model.Rule + wantError string + }{ + { + name: "Valid Rule with Namespace and Ingress", + rule: model.Rule{ + Namespace: "namespace-1", + Ingress: "ingress-1", + }, + wantError: "", + }, + { + name: "Valid Rule with Namespace and empty Ingress", + rule: model.Rule{ + Namespace: "namespace-1", + Ingress: "", + }, + wantError: "", + }, + { + name: "Invalid Namespace pattern", + rule: model.Rule{ + Namespace: "Invalid_Namespace", + Ingress: "ingress-1", + }, + wantError: "invalid namespace pattern: Invalid_Namespace", + }, + { + name: "Invalid Ingress pattern", + rule: model.Rule{ + Namespace: "namespace-1", + Ingress: "Invalid,Ingress", + }, + wantError: "invalid ingress pattern: Invalid,Ingress", + }, + { + name: "Valid Namespace with wildcard and negation", + rule: model.Rule{ + Namespace: "namespace-*", + Ingress: "!ingress-*", + }, + wantError: "", + }, + { + name: "Invalid pattern with special characters", + rule: model.Rule{ + Namespace: "namespace-!", + Ingress: "ingress-1", + }, + wantError: "invalid namespace pattern: namespace-!", + }, + } + + for i, tc := range testCases { + t.Run(tester.Name(i, tc.name), func(t *testing.T) { + err := validateRule(&tc.rule) + if tc.wantError == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.wantError) + } + }) + } +} + +func TestValidate(t *testing.T) { + testCases := []struct { + pattern string + want bool + }{ + // true + {"", true}, // Empty string + {"abc", true}, // Single string + {"abc,def", true}, // Comma-separated strings + {"!abc", true}, // Pattern with an exclamation mark + {"!abc,def", true}, // Pattern with exclamation mark and comma-separated strings + {"abc-def", true}, // String with a hyphen + {"abc*", true}, // String with an asterisk + {"abc,def,ghi", true}, // Multiple comma-separated strings + {"!abc,def-ghi", true}, // Pattern with exclamation mark and hyphen + {"abc,def-ghi,jkl*", true}, // Combination of comma, hyphen, and asterisk + // false + {"abc,", false}, // Invalid pattern ending with a comma + {"abc,def!", false}, // Invalid pattern with an exclamation mark in the wrong place + {"!abc,", false}, // Invalid pattern ending with a comma after exclamation mark + {"abc,def*", true}, // Pattern with an asterisk + {"!abc,def*", true}, // Pattern with exclamation mark and asterisk + {"abc@def", false}, // Invalid pattern with an incorrect special character + {"abc,def,", false}, // Invalid comma placement + {"!abc,def,ghi!", false}, // Exclamation mark in the wrong place + } + + for i, tc := range testCases { + t.Run(tester.Name(i, tc.pattern), func(t *testing.T) { + got := validate(tc.pattern) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/controller/suite_test.go b/controller/suite_test.go similarity index 100% rename from internal/controller/suite_test.go rename to controller/suite_test.go diff --git a/go.mod b/go.mod index 977b997..62590ea 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/jmnote/tester v0.1.1 github.com/onsi/ginkgo/v2 v2.17.1 github.com/onsi/gomega v1.32.0 + github.com/rogpeppe/go-internal v1.10.0 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/stretchr/testify v1.9.0 gopkg.in/yaml.v3 v3.0.1 @@ -36,6 +37,7 @@ require ( github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/cel-go v0.17.8 // indirect github.com/google/gnostic-models v0.6.8 // indirect @@ -72,6 +74,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect + golang.org/x/mod v0.15.0 // indirect golang.org/x/net v0.23.0 // indirect golang.org/x/oauth2 v0.12.0 // indirect golang.org/x/sync v0.6.0 // indirect diff --git a/go.sum b/go.sum index 35a4f82..0b9c3bb 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -134,6 +136,7 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 h1:KfYpVmrjI7JuToy5k8XV3nkapjWx48k4E4JOtVstzQI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0/go.mod h1:SeQhzAEccGVZVEy7aH87Nh0km+utSpo1pTv6eMMop48= go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= @@ -163,11 +166,15 @@ golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfU golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= @@ -176,14 +183,19 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -197,6 +209,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/controller/common_test.go b/internal/controller/common_test.go deleted file mode 100644 index ce0c894..0000000 --- a/internal/controller/common_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package controller - -import ( - "context" - "errors" - - corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/rest" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/manager" -) - -func newScheme() *runtime.Scheme { - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - _ = networkingv1.AddToScheme(scheme) - return scheme -} - -func newFakeClient(objs ...client.Object) client.Client { - return fake.NewClientBuilder().WithScheme(newScheme()).WithObjects(objs...).Build() -} - -func newFakeManager() manager.Manager { - mgr, err := ctrl.NewManager(&rest.Config{}, ctrl.Options{Scheme: newScheme()}) - if err != nil { - panic(err) - } - return mgr -} - -type BadClient1 struct { - client.Client -} - -func (c *BadClient1) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { - return errors.New("Update operation is disabled in this fake client") -} - -func newBadClient1(objs ...client.Object) client.Client { - return &BadClient1{ - Client: newFakeClient(objs...), - } -} diff --git a/internal/controller/configmap_controller_test.go b/internal/controller/configmap_controller_test.go deleted file mode 100644 index bc0d654..0000000 --- a/internal/controller/configmap_controller_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package controller - -import ( - "context" - "testing" - - "github.com/jmnote/tester" - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/kuoss/ingress-annotator/pkg/rulesstore" -) - -func TestConfigMapReconciler_SetupWithManager(t *testing.T) { - testCases := []struct { - name string - namespaceEnv string - objects []client.Object - wantError string - }{ - { - name: "missing POD_NAMESPACE", - namespaceEnv: "", - objects: []client.Object{}, - wantError: "POD_NAMESPACE environment variable is not set or is empty", - }, - { - name: "unmarshal errors", - namespaceEnv: "default", - objects: []client.Object{ - &corev1.ConfigMap{ - ObjectMeta: ctrl.ObjectMeta{ - Name: "ingress-annotator-rules", - Namespace: "default", - }, - Data: map[string]string{ - "rule1": "invalid", - }, - }, - }, - wantError: "yaml: unmarshal errors", - }, - - { - name: "successful setup 1", - namespaceEnv: "default", - objects: []client.Object{ - &corev1.ConfigMap{ - ObjectMeta: ctrl.ObjectMeta{ - Name: "ingress-annotator-rules", - Namespace: "default", - }, - }, - }, - wantError: "", - }, - { - name: "successful setup 2", - namespaceEnv: "default", - objects: []client.Object{ - &corev1.ConfigMap{ - ObjectMeta: ctrl.ObjectMeta{ - Name: "ingress-annotator-rules", - Namespace: "default", - }, - Data: map[string]string{ - "rule1": "annotations:\n key1: value1\nnamespace: test-namespace\ningress: test-ingress", - }, - }, - }, - wantError: "", - }, - } - - for i, tc := range testCases { - t.Run(tester.Name(i, tc.name), func(t *testing.T) { - t.Setenv("POD_NAMESPACE", tc.namespaceEnv) - - reconciler := &ConfigMapReconciler{ - Client: newFakeClient(tc.objects...), - Scheme: newScheme(), - RulesStore: rulesstore.New(), - } - err := reconciler.SetupWithManager(newFakeManager()) - if tc.wantError != "" { - assert.ErrorContains(t, err, tc.wantError) - } else { - assert.NoError(t, err) - } - }) - } -} -func TestConfigMapReconciler_Reconcile(t *testing.T) { - testCases := []struct { - name string - configMeta types.NamespacedName - objects []client.Object - requestMeta types.NamespacedName - wantResult reconcile.Result - wantError string - }{ - { - name: "successful reconciliation", - configMeta: types.NamespacedName{Namespace: "default", Name: "ingress-annotator-rules"}, - objects: []client.Object{ - &corev1.ConfigMap{ - ObjectMeta: ctrl.ObjectMeta{Namespace: "default", Name: "ingress-annotator-rules"}, - Data: map[string]string{"rule1": "annotations:\n key: value\nnamespace: default\ningress: my-ingress"}, - }, - }, - requestMeta: types.NamespacedName{Namespace: "default", Name: "ingress-annotator-rules"}, - wantResult: reconcile.Result{}, - wantError: "", - }, - { - name: "successful reconciliation for other cm", - configMeta: types.NamespacedName{Namespace: "default", Name: "ingress-annotator-rules"}, - objects: []client.Object{ - &corev1.ConfigMap{ - ObjectMeta: ctrl.ObjectMeta{Namespace: "default", Name: "ingress-annotator-rules"}, - Data: map[string]string{"rule1": "annotations:\n key: value\nnamespace: default\ningress: my-ingress"}, - }, - }, - requestMeta: types.NamespacedName{Namespace: "default", Name: "xxx"}, - wantResult: reconcile.Result{}, - wantError: "", - }, - { - name: "config map not found", - configMeta: types.NamespacedName{Namespace: "default", Name: "ingress-annotator-rules"}, - objects: []client.Object{}, - requestMeta: types.NamespacedName{Namespace: "default", Name: "ingress-annotator-rules"}, - wantResult: reconcile.Result{}, - wantError: `updateRulesWithConfigMap err: getConfigMap err: configmaps "ingress-annotator-rules" not found`, - }, - } - - for i, tc := range testCases { - t.Run(tester.Name(i, tc.name), func(t *testing.T) { - ctx := context.Background() - client := newFakeClient(tc.objects...) - store := rulesstore.New() - reconciler := &ConfigMapReconciler{ - Client: client, - Scheme: runtime.NewScheme(), - ConfigMeta: tc.configMeta, - RulesStore: store, - } - - req := ctrl.Request{NamespacedName: tc.requestMeta} - result, err := reconciler.Reconcile(ctx, req) - if tc.wantError == "" { - assert.NoError(t, err) - } else { - assert.EqualError(t, err, tc.wantError) - } - assert.Equal(t, tc.wantResult, result) - }) - } -} - -func TestConfigMapReconciler_updateRulesWithConfigMap(t *testing.T) { - testCases := []struct { - name string - configMap *corev1.ConfigMap - expectedError bool - }{ - { - name: "valid config map", - configMap: &corev1.ConfigMap{ - ObjectMeta: ctrl.ObjectMeta{ - Name: "ingress-annotator-rules", - Namespace: "default", - }, - Data: map[string]string{ - "rule1": "annotations:\n key: value\nnamespace: default\ningress: my-ingress", - }, - }, - expectedError: false, - }, - { - name: "invalid config map data", - configMap: &corev1.ConfigMap{ - ObjectMeta: ctrl.ObjectMeta{ - Name: "ingress-annotator-rules", - Namespace: "default", - }, - Data: map[string]string{ - "rule1": "invalid yaml", - }, - }, - expectedError: true, - }, - } - - for i, tc := range testCases { - t.Run(tester.Name(i, tc.name), func(t *testing.T) { - client := newFakeClient(tc.configMap) - store := rulesstore.New() - reconciler := &ConfigMapReconciler{ - Client: client, - Scheme: runtime.NewScheme(), - ConfigMeta: types.NamespacedName{Namespace: "default", Name: "ingress-annotator-rules"}, - RulesStore: store, - } - - err := reconciler.updateRulesWithConfigMap(context.Background()) - if tc.expectedError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - rules := store.GetRules() - assert.NotNil(t, rules) - assert.Contains(t, *rules, "rule1") - } - }) - } -} diff --git a/pkg/rulesstore/rulesstore_test.go b/pkg/rulesstore/rulesstore_test.go deleted file mode 100644 index 26629c6..0000000 --- a/pkg/rulesstore/rulesstore_test.go +++ /dev/null @@ -1,267 +0,0 @@ -package rulesstore - -import ( - "sync" - "testing" - - "github.com/jmnote/tester" - "github.com/kuoss/ingress-annotator/pkg/model" - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" -) - -func TestRulesStore(t *testing.T) { - mockData := map[string]string{ - "rule1": ` -annotations: - key1: value1 -namespace: test-namespace -ingress: test-ingress`, - "rule2": ` -annotations: - key2: value2 -namespace: another-namespace`, - } - cm := &corev1.ConfigMap{ - Data: mockData, - } - - store := New() - err := store.UpdateRules(cm) - assert.NoError(t, err) - - want := &model.Rules{ - "rule1": { - Annotations: model.Annotations{"key1": "value1"}, - Namespace: "test-namespace", - Ingress: "test-ingress", - }, - "rule2": { - Annotations: model.Annotations{"key2": "value2"}, - Namespace: "another-namespace", - Ingress: "", - }, - } - got := store.GetRules() - assert.Equal(t, want, got) -} - -func TestGetRuleValueFromText(t *testing.T) { - yamlText := ` -annotations: - key1: value1 -namespace: test-namespace -ingress: test-ingress` - want := &model.Rule{ - Annotations: model.Annotations{"key1": "value1"}, - Namespace: "test-namespace", - Ingress: "test-ingress", - } - - got, err := getRuleValueFromText(yamlText) - assert.NoError(t, err) - assert.Equal(t, want, got) -} - -func TestConcurrency(t *testing.T) { - store := New() - store.Rules = &model.Rules{ - "initialRule": { - Annotations: model.Annotations{"initialKey": "initialValue"}, - Namespace: "initial-namespace", - Ingress: "initial-ingress", - }, - } - - var wg sync.WaitGroup - - // Test concurrent access to GetRules and UpdateRules - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() - store.GetRules() - }() - - wg.Add(1) - go func(_ int) { - defer wg.Done() - mockData := map[string]string{ - "rule": ` -annotations: - key: value -namespace: namespace`, - } - cm := &corev1.ConfigMap{ - Data: mockData, - } - _ = store.UpdateRules(cm) - }(i) - } - - wg.Wait() -} - -func TestUpdateRules(t *testing.T) { - tests := []struct { - name string - configMapData map[string]string - expectError bool - }{ - { - name: "Valid rule", - configMapData: map[string]string{ - "rule1": "namespace: test-namespace\ningress: test-ingress", - }, - expectError: false, - }, - { - name: "Invalid namespace pattern", - configMapData: map[string]string{ - "rule1": "namespace: invalid_namespace!\ningress: test-ingress", - }, - expectError: true, - }, - { - name: "Invalid ingress pattern", - configMapData: map[string]string{ - "rule1": "namespace: test-namespace\ningress: invalid_ingress!", - }, - expectError: true, - }, - { - name: "Invalid YAML", - configMapData: map[string]string{ - "rule1": "namespace: test-namespace\ningress", - }, - expectError: true, - }, - { - name: "Empty ConfigMap", - configMapData: map[string]string{}, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rs := New() - cm := &corev1.ConfigMap{ - Data: tt.configMapData, - } - - err := rs.UpdateRules(cm) - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} -func TestValidateRule(t *testing.T) { - testCases := []struct { - name string - rule model.Rule - wantErr bool - errMsg string - }{ - { - name: "Valid Rule with Namespace and Ingress", - rule: model.Rule{ - Namespace: "namespace-1", - Ingress: "ingress-1", - }, - wantErr: false, - }, - { - name: "Valid Rule with Namespace and empty Ingress", - rule: model.Rule{ - Namespace: "namespace-1", - Ingress: "", - }, - wantErr: false, - }, - { - name: "Invalid Namespace pattern", - rule: model.Rule{ - Namespace: "Invalid_Namespace", - Ingress: "ingress-1", - }, - wantErr: true, - errMsg: "invalid namespace pattern: Invalid_Namespace", - }, - { - name: "Invalid Ingress pattern", - rule: model.Rule{ - Namespace: "namespace-1", - Ingress: "Invalid,Ingress", - }, - wantErr: true, - errMsg: "invalid ingress pattern: Invalid,Ingress", - }, - { - name: "Valid Namespace with wildcard and negation", - rule: model.Rule{ - Namespace: "namespace-*", - Ingress: "!ingress-*", - }, - wantErr: false, - }, - { - name: "Invalid pattern with special characters", - rule: model.Rule{ - Namespace: "namespace-!", - Ingress: "ingress-1", - }, - wantErr: true, - errMsg: "invalid namespace pattern: namespace-!", - }, - } - - for i, tc := range testCases { - t.Run(tester.Name(i, tc.name), func(t *testing.T) { - err := validateRule(&tc.rule) - if tc.wantErr { - assert.Error(t, err) - assert.EqualError(t, err, tc.errMsg) - } else { - assert.NoError(t, err) - } - }) - } -} -func TestValidate(t *testing.T) { - testCases := []struct { - pattern string - want bool - }{ - // true - {"", true}, // Empty string - {"abc", true}, // Single string - {"abc,def", true}, // Comma-separated strings - {"!abc", true}, // Pattern with an exclamation mark - {"!abc,def", true}, // Pattern with exclamation mark and comma-separated strings - {"abc-def", true}, // String with a hyphen - {"abc*", true}, // String with an asterisk - {"abc,def,ghi", true}, // Multiple comma-separated strings - {"!abc,def-ghi", true}, // Pattern with exclamation mark and hyphen - {"abc,def-ghi,jkl*", true}, // Combination of comma, hyphen, and asterisk - // false - {"abc,", false}, // Invalid pattern ending with a comma - {"abc,def!", false}, // Invalid pattern with an exclamation mark in the wrong place - {"!abc,", false}, // Invalid pattern ending with a comma after exclamation mark - {"abc,def*", true}, // Pattern with an asterisk - {"!abc,def*", true}, // Pattern with exclamation mark and asterisk - {"abc@def", false}, // Invalid pattern with an incorrect special character - {"abc,def,", false}, // Invalid comma placement - {"!abc,def,ghi!", false}, // Exclamation mark in the wrong place - } - - for i, tc := range testCases { - t.Run(tester.Name(i, tc.pattern), func(t *testing.T) { - got := validate(tc.pattern) - assert.Equal(t, tc.want, got) - }) - } -}