diff --git a/operator/api/v1/hotnews_types.go b/operator/api/v1/hotnews_types.go deleted file mode 100644 index 33812d4..0000000 --- a/operator/api/v1/hotnews_types.go +++ /dev/null @@ -1,109 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package v1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// HotNewsSpec defines the desired state of HotNews. -// -// This struct will be used to retrieve news by the criteria, specified here -// For example, we can specify keywords, date range, feeds and feed groups -// And then we will make requests to our news aggregator server with this parameters, and get the news -type HotNewsSpec struct { - // Keywords is a comma-separated list of keywords which will be used to search news - // +kubebuilder:validation:Required - Keywords []string `json:"keywords"` - - // DateStart is a news starting date in format "YYYY-MM-DD", can be empty - // +optional - DateStart string `json:"dateStart,omitempty"` - - // DateEnd is a news final date in format "YYYY-MM-DD", can be empty - DateEnd string `json:"dateEnd,omitempty"` - - // Feeds is a list of Feeds CRD, which will be used to subscribe to news - // +optional - Feeds []string `json:"feeds,omitempty"` - - // FeedGroups are available sections of feeds from `hotNew-group-source` ConfigMap - // +optional - FeedGroups []string `json:"feedGroups,omitempty"` - - // SummaryConfig summary of observed hot news - // +optional - SummaryConfig SummaryConfig `json:"summaryConfig,omitempty"` -} - -// SummaryConfig struct defines the configuration for the summary of hot news -// It stores the number of titles to show and store in HotNewsStatus.ArticlesTitles -type SummaryConfig struct { - // TitlesCount is a number of titles to show in the summary - TitlesCount int `json:"titlesCount"` -} - -// HotNewsStatus defines the observed state of HotNews -type HotNewsStatus struct { - // ArticlesCount displays total amount of news by the criteria - ArticlesCount int `json:"articlesCount"` - - // NewsLink is a link which will be constructed to get all news by the certain criteria - NewsLink string `json:"newsLink"` - - // ArticlesTitles contains a list of titles of first 10 articles - ArticlesTitles []string `json:"articlesTitles"` -} - -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status - -// HotNews is the Schema for the hotnews API -type HotNews struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec HotNewsSpec `json:"spec,omitempty"` - Status HotNewsStatus `json:"status,omitempty"` -} - -// +kubebuilder:object:root=true - -// HotNewsList contains a list of HotNews -type HotNewsList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []HotNews `json:"items"` -} - -func init() { - SchemeBuilder.Register(&Feed{}, &FeedList{}) - SchemeBuilder.Register(&HotNews{}, &HotNewsList{}) -} - -// InitHotNewsStatus func initializes HotNews.Status object with the provided data -func (r *HotNews) InitHotNewsStatus(articlesCount int, requestUrl string, articlesTitles []string) { - r.Status.ArticlesCount = articlesCount - r.Status.NewsLink = requestUrl - - var articles []string - - for i := 0; i <= len(articlesTitles)-1 && i < r.Spec.SummaryConfig.TitlesCount; i++ { - articles = append(articles, articlesTitles[i]) - } - r.Status.ArticlesTitles = articles -} diff --git a/operator/api/v1/hotnews_types_test.go b/operator/api/v1/hotnews_types_test.go deleted file mode 100644 index 0e7b37b..0000000 --- a/operator/api/v1/hotnews_types_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package v1 - -import ( - "github.com/stretchr/testify/assert" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "testing" -) - -func TestHotNews_InitHotNewsStatus(t *testing.T) { - type fields struct { - TypeMeta v1.TypeMeta - ObjectMeta v1.ObjectMeta - Spec HotNewsSpec - Status HotNewsStatus - } - type args struct { - articlesCount int - requestUrl string - articlesTitles []string - } - tests := []struct { - name string - fields fields - args args - }{ - { - name: "Test valid execution", - args: args{ - articlesCount: 10, - requestUrl: "http://test.com", - articlesTitles: []string{"test1", "test2", "test3", "test4", "test5", "test6", "test7", "test8", "test9", "test10"}, - }, - fields: fields{ - TypeMeta: v1.TypeMeta{}, - ObjectMeta: v1.ObjectMeta{}, - Spec: HotNewsSpec{ - SummaryConfig: SummaryConfig{TitlesCount: 5}, - }, - Status: HotNewsStatus{}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &HotNews{ - TypeMeta: tt.fields.TypeMeta, - ObjectMeta: tt.fields.ObjectMeta, - Spec: tt.fields.Spec, - Status: tt.fields.Status, - } - r.InitHotNewsStatus(tt.args.articlesCount, tt.args.requestUrl, tt.args.articlesTitles) - assert.Equal(t, r.Status.ArticlesCount, tt.args.articlesCount) - }) - } -} diff --git a/operator/api/v1/hotnews_webhook.go b/operator/api/v1/hotnews_webhook.go deleted file mode 100644 index 1b07eee..0000000 --- a/operator/api/v1/hotnews_webhook.go +++ /dev/null @@ -1,264 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package v1 - -import ( - "context" - "errors" - "fmt" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/selection" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/webhook" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" - "slices" - "time" -) - -const ( - // errNoFeeds is an error message indicating that user hasn't specified any feeds - errNoFeeds = "either feeds or feedGroups should be specified" - - // errInvalidDateRange is an error message indicating input of wrong date range - errInvalidDateRange = "DateStart should be before than DateEnd" - - // errWrongFeedGroupName is an error message for wrong hotNew group name - errWrongFeedGroupName = "hotNew group name is not found in the ConfigMap, please check the hotNew group name" - - // FeedGroupsNamespace is a namespace where hotNew groups are stored - FeedGroupsNamespace = "operator-system" - - // FeedGroupsConfigMapName is a name of the default ConfigMap which contains our hotNew groups names and sources - FeedGroupsConfigMapName = "feed-group-source" - - // FeedGroupLabel is a label for ConfigMap which contains our hotNew groups names and sources - FeedGroupLabel = "feed-group-source" - - // HotNewsFinalizer is a finalizer for HotNews resource which will be added when the resource is created - // and removed when the resource is deleted - HotNewsFinalizer = "finalizer.hotnews.newsaggregator.teamdev.com" -) - -var ( - hotnewslog = logf.Log.WithName("hotnews-resource") -) - -// SetupWebhookWithManager will setup the manager to manage the webhooks -func (r *HotNews) SetupWebhookWithManager(mgr ctrl.Manager) error { - return ctrl.NewWebhookManagedBy(mgr). - For(r). - Complete() -} - -// +kubebuilder:webhook:path=/mutate-newsaggregator-teamdev-com-v1-hotnews,mutating=true,failurePolicy=fail,sideEffects=None,groups=newsaggregator.teamdev.com,resources=hotnews,verbs=create;update;delete,versions=v1,name=mhotnews.kb.io,admissionReviewVersions=v1 - -var _ webhook.Defaulter = &HotNews{} - -// Default implements webhook.Defaulter so a webhook will be registered for the type -// -// This webhook will set the default values for the HotNews resource -// In particular, if the user hasn't specified the number of titles to show in the summary, we will set it to 10 -func (r *HotNews) Default() { - hotnewslog.Info("default", "name", r.Name) - - if r.Spec.SummaryConfig.TitlesCount == 0 { - r.Spec.SummaryConfig.TitlesCount = 10 - } - - if r.Spec.Feeds == nil && r.Spec.FeedGroups == nil { - var err error - r.Spec.Feeds, err = r.getAllFeeds() - if err != nil { - hotnewslog.Error(err, "error getting feeds", "name", r.Name) - } - } -} - -// +kubebuilder:webhook:path=/validate-newsaggregator-teamdev-com-v1-hotnews,mutating=false,failurePolicy=fail,sideEffects=None,groups=newsaggregator.teamdev.com,resources=hotnews,verbs=create;update;delete,versions=v1,name=vhotnews.kb.io,admissionReviewVersions=v1 - -var _ webhook.Validator = &HotNews{} - -// ValidateCreate implements webhook.Validator so a webhook will be registered for the type -// -// It is called when the HotNews resource is created -// Validating webhook will check if the HotNews resource is correct -// In particular, it checks if the DateStart is before DateEnd and if all hotNew group names are correct -// Also, it checks if user-specified feeds or feedGroups are correct by these criteria: -// FeedGroups should be present in the feed-group-source ConfigMap -func (r *HotNews) ValidateCreate() (admission.Warnings, error) { - hotnewslog.Info("validate create", "name", r.Name) - err := r.validateHotNews() - if err != nil { - return nil, err - } - - return nil, nil -} - -// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type -// -// ValidateUpdate is called when the HotNews resource is Updated -// Validating webhook will check if the HotNews resource is correct -// In particular, it checks if the DateStart is before DateEnd and if all hotNew group names are correct -// Also, it checks if user-specified feeds or feedGroups are correct by these criteria: -// FeedGroups should be present in the feed-group-source ConfigMap -func (r *HotNews) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { - hotnewslog.Info("validate update", "name", r.Name) - err := r.validateHotNews() - if err != nil { - return nil, err - } - - return nil, nil -} - -// ValidateDelete implements webhook.Validator so a webhook will be registered for the type -func (r *HotNews) ValidateDelete() (admission.Warnings, error) { - hotnewslog.Info("validate delete", "name", r.Name) - - return nil, nil -} - -// GetFeedGroupNames returns all config maps which contain hotNew groups names -func (r *HotNews) GetFeedGroupNames(ctx context.Context) ([]string, error) { - s, err := labels.NewRequirement(FeedGroupLabel, selection.Exists, nil) - if err != nil { - return nil, err - } - - var configMaps v1.ConfigMapList - err = k8sClient.List(ctx, &configMaps, &client.ListOptions{ - LabelSelector: labels.NewSelector().Add(*s), - }) - if err != nil { - return nil, err - } - - var feedGroups []string - for _, configMap := range configMaps.Items { - for _, source := range r.Spec.FeedGroups { - if _, exists := configMap.Data[source]; exists { - feedGroups = append(feedGroups, source) - } - } - } - - return feedGroups, nil -} - -// getAllFeeds returns all feeds in the namespace -// It is used to set the default value for the feeds field in the HotNews resource -func (r *HotNews) getAllFeeds() ([]string, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - - var feedList FeedList - err := k8sClient.List(ctx, &feedList, client.InNamespace(r.Namespace)) - if err != nil { - return nil, err - } - - var feedNames []string - for _, feed := range feedList.Items { - feedNames = append(feedNames, feed.Spec.Name) - } - - return feedNames, nil -} - -// validateHotNews verifies if the fields in HotNews resource are correct -// -// In particular, it checks if the DateStart is before DateEnd and if all hotNew group names are correct, and -// if feeds or feedGroups exists in our news aggregator. -func (r *HotNews) validateHotNews() error { - err := validateHotNews(r.Spec) - if err != nil { - return err - } - - if r.Spec.Feeds == nil && r.Spec.FeedGroups == nil { - return fmt.Errorf(errNoFeeds) - } - - err = r.feedsExists() - if err != nil { - return err - } - - err = r.feedGroupsExists() - if err != nil { - return err - } - - return nil -} - -// feedExists checks if the given list of feeds exist in the namespace -func (r *HotNews) feedsExists() error { - if r.Spec.Feeds == nil { - return nil - } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - - var feedList FeedList - err := k8sClient.List(ctx, &feedList, &client.ListOptions{ - Namespace: r.Namespace, - }) - if err != nil { - return err - } - - feedNames := []string{} - for _, feed := range feedList.Items { - feedNames = append(feedNames, feed.Spec.Name) - } - - for _, source := range r.Spec.Feeds { - if !slices.Contains(feedNames, source) { - return errors.New("feed name is not found in the namespace, please check the feed name") - } - } - - return err -} - -// feedGroupsExists checks if the given list of feed groups exist in the config map -func (r *HotNews) feedGroupsExists() error { - if r.Spec.FeedGroups == nil { - return nil - } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - - feedGroups, err := r.GetFeedGroupNames(ctx) - if err != nil { - return err - } - - for _, source := range r.Spec.FeedGroups { - if !slices.Contains(feedGroups, source) { - return errors.New(errWrongFeedGroupName) - } - } - - return nil -} diff --git a/operator/api/v1/hotnews_webhook_test.go b/operator/api/v1/hotnews_webhook_test.go deleted file mode 100644 index 71fd2fe..0000000 --- a/operator/api/v1/hotnews_webhook_test.go +++ /dev/null @@ -1,522 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package v1 - -import ( - "github.com/stretchr/testify/assert" - v1 "k8s.io/api/core/v1" - v12 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - controllerruntime "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" - "testing" -) - -func TestFeed_validateHotNews(t *testing.T) { - scheme := runtime.NewScheme() - _ = AddToScheme(scheme) - _ = v1.AddToScheme(scheme) - - existingFeedList := &FeedList{ - Items: []Feed{ - { - ObjectMeta: v12.ObjectMeta{ - Namespace: FeedGroupsNamespace, - Name: FeedGroupsConfigMapName, - }, - Spec: FeedSpec{ - Name: "abc", - }, - }, - }, - } - - existingHotNewsList := &HotNewsList{ - Items: []HotNews{ - { - ObjectMeta: v12.ObjectMeta{ - Namespace: FeedGroupsNamespace, - Name: FeedGroupsConfigMapName, - }, - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - Feeds: []string{"abc"}, - }, - }, - }, - } - - k8sClient = fake.NewClientBuilder(). - WithScheme(scheme). - WithLists(existingHotNewsList, existingFeedList). - WithObjects(&v1.ConfigMap{ - ObjectMeta: v12.ObjectMeta{ - Namespace: FeedGroupsNamespace, - Name: FeedGroupsConfigMapName, - }, - Data: map[string]string{ - "sport": "abc", - }, - }).Build() - - var tests = []struct { - name string - hotNew *HotNews - expectedErr bool - setup func() - }{ - { - name: "Successful validation", - hotNew: &HotNews{ - ObjectMeta: v12.ObjectMeta{ - Namespace: FeedGroupsNamespace, - Name: FeedGroupsConfigMapName, - }, - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - Feeds: []string{"feed1"}, - }, - }, - setup: func() {}, - expectedErr: false, - }, - { - name: "Validation failure due to empty feeds and feed groups", - hotNew: &HotNews{ - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - }, - }, - setup: func() {}, - expectedErr: true, - }, - { - name: "Validation failure due to invalid hotNew date range", - hotNew: &HotNews{ - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-03", - DateEnd: "2021-01-02", - }, - }, - setup: func() {}, - expectedErr: true, - }, - { - name: "Validation failure due to invalid dates", - hotNew: &HotNews{ - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "ABCC-AA-BB", - DateEnd: "BBCA-AA-BB", - }, - }, - setup: func() {}, - expectedErr: true, - }, - { - name: "Validation with feed groups", - hotNew: &HotNews{ - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "ABCC-AA-BB", - FeedGroups: []string{"sport"}, - }, - }, - setup: func() {}, - expectedErr: true, - }, - { - name: "K8s client not est", - hotNew: &HotNews{ - ObjectMeta: v12.ObjectMeta{ - Namespace: "non-eadssxistent", - }, - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "ABCC-AA-BB", - FeedGroups: []string{"sport"}, - }, - }, - setup: func() { - k8sClient = fake.NewClientBuilder().WithScheme(nil).Build() - }, - expectedErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.setup() - err := tt.hotNew.validateHotNews() - - if tt.expectedErr { - assert.NotNil(t, err) - } else { - assert.Nil(t, err) - } - }) - } -} - -func TestHotNews_ValidateCreate(t *testing.T) { - scheme := runtime.NewScheme() - _ = AddToScheme(scheme) - _ = v1.AddToScheme(scheme) - - existingFeedList := &FeedList{ - Items: []Feed{ - { - Spec: FeedSpec{ - Name: "abc", - }, - }, - }, - } - - existingHotNewsList := &HotNewsList{ - Items: []HotNews{ - { - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - Feeds: []string{"abc"}, - }, - }, - }, - } - - k8sClient = fake.NewClientBuilder(). - WithScheme(scheme). - WithLists(existingHotNewsList, existingFeedList). - WithObjects(&v1.ConfigMap{ - ObjectMeta: v12.ObjectMeta{ - Namespace: FeedGroupsNamespace, - Name: FeedGroupsConfigMapName, - }, - Data: map[string]string{ - "sport": "abc", - }, - }).Build() - - type fields struct { - TypeMeta v12.TypeMeta - ObjectMeta v12.ObjectMeta - Spec HotNewsSpec - Status HotNewsStatus - } - tests := []struct { - name string - fields fields - want admission.Warnings - wantErr bool - }{ - { - name: "Successful validation", - fields: fields{ - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - Feeds: []string{"abc"}, - }, - }, - want: nil, - wantErr: false, - }, - { - name: "Successful validation with feed groups", - fields: fields{ - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - FeedGroups: []string{"sport"}, - }, - }, - want: nil, - wantErr: false, - }, - { - name: "Not Successful validation with feed groups", - fields: fields{ - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - FeedGroups: []string{"abbbc"}, - }, - }, - want: nil, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &HotNews{ - TypeMeta: tt.fields.TypeMeta, - ObjectMeta: tt.fields.ObjectMeta, - Spec: tt.fields.Spec, - Status: tt.fields.Status, - } - got, err := r.ValidateCreate() - if tt.wantErr { - assert.NotNil(t, err) - } else { - assert.Nil(t, err) - } - assert.Equalf(t, tt.want, got, "ValidateCreate()") - }) - } -} - -func TestHotNews_ValidateDelete(t *testing.T) { - type fields struct { - TypeMeta v12.TypeMeta - ObjectMeta v12.ObjectMeta - Spec HotNewsSpec - Status HotNewsStatus - } - tests := []struct { - name string - fields fields - want admission.Warnings - wantErr bool - }{ - { - name: "Successful validation", - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &HotNews{ - TypeMeta: tt.fields.TypeMeta, - ObjectMeta: tt.fields.ObjectMeta, - Spec: tt.fields.Spec, - Status: tt.fields.Status, - } - got, err := r.ValidateDelete() - if tt.wantErr { - assert.NotNil(t, err) - } else { - assert.Nil(t, err) - } - assert.Equalf(t, tt.want, got, "ValidateDelete()") - }) - } -} - -func TestHotNews_ValidateUpdate(t *testing.T) { - scheme := runtime.NewScheme() - _ = AddToScheme(scheme) - _ = v1.AddToScheme(scheme) - - existingFeedList := &FeedList{ - Items: []Feed{ - { - Spec: FeedSpec{ - Name: "abc", - }, - }, - }, - } - - existingHotNewsList := &HotNewsList{ - Items: []HotNews{ - { - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - Feeds: []string{"abc"}, - }, - }, - }, - } - - k8sClient = fake.NewClientBuilder(). - WithScheme(scheme). - WithLists(existingHotNewsList, existingFeedList). - WithObjects(&v1.ConfigMap{ - ObjectMeta: v12.ObjectMeta{ - Namespace: FeedGroupsNamespace, - Name: FeedGroupsConfigMapName, - }, - Data: map[string]string{}, - }).Build() - - type fields struct { - TypeMeta v12.TypeMeta - ObjectMeta v12.ObjectMeta - Spec HotNewsSpec - Status HotNewsStatus - } - type args struct { - old runtime.Object - } - tests := []struct { - name string - fields fields - args args - want admission.Warnings - wantErr bool - }{ - { - name: "Successful validation", - fields: fields{ - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - Feeds: []string{"abc"}, - }, - }, - want: nil, - wantErr: false, - }, - { - name: "Successful validation", - fields: fields{ - Spec: HotNewsSpec{ - Keywords: []string{"test"}, - DateStart: "2021-01-01", - DateEnd: "2021-01-02", - }, - }, - want: nil, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &HotNews{ - TypeMeta: tt.fields.TypeMeta, - ObjectMeta: tt.fields.ObjectMeta, - Spec: tt.fields.Spec, - Status: tt.fields.Status, - } - got, err := r.ValidateUpdate(tt.args.old) - if tt.wantErr { - assert.NotNil(t, err) - } else { - assert.Nil(t, err) - } - assert.Equalf(t, tt.want, got, "ValidateUpdate(%v)", tt.args.old) - }) - } -} - -func TestHotNews_Default(t *testing.T) { - type fields struct { - TypeMeta v12.TypeMeta - ObjectMeta v12.ObjectMeta - Spec HotNewsSpec - Status HotNewsStatus - } - tests := []struct { - name string - fields fields - }{ - { - name: "Successful defaulting", - fields: fields{ - Spec: HotNewsSpec{}, - Status: HotNewsStatus{}, - ObjectMeta: v12.ObjectMeta{}, - TypeMeta: v12.TypeMeta{}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &HotNews{ - TypeMeta: tt.fields.TypeMeta, - ObjectMeta: tt.fields.ObjectMeta, - Spec: tt.fields.Spec, - Status: tt.fields.Status, - } - r.Default() - }) - } -} - -func TestHotNews_SetupWebhookWithManager(t *testing.T) { - schema := runtime.NewScheme() - assert.Nil(t, AddToScheme(schema)) - assert.Nil(t, v1.AddToScheme(schema)) - - mgr, err := controllerruntime.NewManager(controllerruntime.GetConfigOrDie(), controllerruntime.Options{ - Scheme: schema, - }) - assert.Nil(t, err) - - type fields struct { - TypeMeta v12.TypeMeta - ObjectMeta v12.ObjectMeta - Spec HotNewsSpec - Status HotNewsStatus - } - type args struct { - mgr controllerruntime.Manager - } - tests := []struct { - name string - fields fields - args args - wantErr bool - }{ - { - name: "Successful webhook setup", - fields: fields{ - Spec: HotNewsSpec{}, - Status: HotNewsStatus{}, - ObjectMeta: v12.ObjectMeta{}, - TypeMeta: v12.TypeMeta{}, - }, - args: args{ - mgr: mgr, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &HotNews{ - TypeMeta: tt.fields.TypeMeta, - ObjectMeta: tt.fields.ObjectMeta, - Spec: tt.fields.Spec, - Status: tt.fields.Status, - } - - if tt.wantErr { - assert.NotNil(t, r.SetupWebhookWithManager(tt.args.mgr)) - } else { - assert.Nil(t, r.SetupWebhookWithManager(tt.args.mgr)) - } - }) - } -} diff --git a/operator/cmd/main.go b/operator/cmd/main.go index 95d6d99..1a080da 100644 --- a/operator/cmd/main.go +++ b/operator/cmd/main.go @@ -150,18 +150,7 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Feed") os.Exit(1) } - if err = (&controller.HotNewsReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr, newsFetchingEndpoint); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "HotNews") - os.Exit(1) - } if os.Getenv("ENABLE_WEBHOOKS") != "false" { - if err = (&newsaggregatorv1.HotNews{}).SetupWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "HotNews") - os.Exit(1) - } if err = (&newsaggregatorv1.Feed{}).SetupWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "Feed") os.Exit(1) diff --git a/operator/internal/controller/feed_controller.go b/operator/internal/controller/feed_controller.go index e7b6a32..26ce19e 100644 --- a/operator/internal/controller/feed_controller.go +++ b/operator/internal/controller/feed_controller.go @@ -31,7 +31,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" - "slices" newsaggregatorv1 "teamdev.com/go-gator/api/v1" ) @@ -113,9 +112,6 @@ func (r *FeedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. return ctrl.Result{}, nil } - // question: on the review Vitalii said that I should check if key exists in the map - // but, the logic behind this was that if the key doesn't exist, it means that the feed is new - // and I should create it. Otherwise, I should update it isNew := feed.Status.Conditions[newsaggregatorv1.TypeFeedCreated] == newsaggregatorv1.FeedConditions{} && feed.Status.Conditions[newsaggregatorv1.TypeFeedCreated].Status == false @@ -143,11 +139,6 @@ func (r *FeedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. return ctrl.Result{}, err } - err = r.updateAllHotNewsInNamespaceByFeed(ctx, &feed) - if err != nil { - return ctrl.Result{}, err - } - return res, nil } @@ -314,23 +305,3 @@ func (r *FeedReconciler) handleDelete(feed *newsaggregatorv1.Feed) (ctrl.Result, return ctrl.Result{}, nil } - -// updateAllHotNewsInNamespaceByFeed updates all HotNews objects in the namespace which contains the feed. -func (r *FeedReconciler) updateAllHotNewsInNamespaceByFeed(ctx context.Context, feed *newsaggregatorv1.Feed) error { - var hotNewsList newsaggregatorv1.HotNewsList - err := r.Client.List(ctx, &hotNewsList, client.InNamespace(feed.Namespace)) - if err != nil { - return err - } - - for _, hotNews := range hotNewsList.Items { - if slices.Contains(hotNews.Spec.Feeds, feed.Spec.Name) { - err = r.Client.Update(ctx, &hotNews) - if err != nil { - return err - } - } - } - - return nil -} diff --git a/operator/internal/controller/hotnews_controller.go b/operator/internal/controller/hotnews_controller.go deleted file mode 100644 index 9f458d3..0000000 --- a/operator/internal/controller/hotnews_controller.go +++ /dev/null @@ -1,471 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "context" - "crypto/tls" - "encoding/json" - "fmt" - v1 "k8s.io/api/core/v1" - k8sErrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/util/validation/field" - "net/http" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "strings" - "time" - - newsaggregatorv1 "teamdev.com/go-gator/api/v1" -) - -const ( - // errFailedToConstructRequestUrl error message which is returned when failed to construct request URL - errFailedToConstructRequestUrl = "failed to construct request URL" - - // errFailedToCreateRequest is returned when failed to create a new request - errFailedToCreateRequest = "failed to create a new request" - - // errFailedToSendRequest indicates error during sending an HTTP request - errFailedToSendRequest = "failed to send a request" - - // errFailedToDecodeResBody indicates that error occurred when failed to unmarshal response body - errFailedToDecodeResBody = "failed to decode response body" - - // errFailedToCloseResponseBody is returned when failed to close response body - errFailedToCloseResponseBody = "failed to close response body" - - // errWrongFeedGroupName is returned when the feed group name is wrong - errWrongFeedGroupName = "wrong feed group name, please check the feed group name and try again" -) - -// HotNewsReconciler reconciles a HotNews object -// Whenever status of HotNews CRD is updated, it sends a request to the news aggregator server -// to retrieve news with the specified parameters. -// It also watches for changes in the ConfigMap with the feed groups, and in the Feed CRD. -// -// Before sending a request to the news aggregator server, it verifies if the arguments are correct: -// - keywords are provided -// - date range is correct -// - feeds or feed groups are provided, and they exists in news aggregator server -// Then, it constructs a request URL and sends a request to the news aggregator server, parses the response -// and updates the HotNews object. -type HotNewsReconciler struct { - serverUrl string - client.Client - Scheme *runtime.Scheme -} - -// +kubebuilder:rbac:groups=newsaggregator.teamdev.com,resources=hotnews;feeds,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=newsaggregator.teamdev.com,resources=hotnews/status;feeds,verbs=get;update;patch -// +kubebuilder:rbac:groups=newsaggregator.teamdev.com,resources=hotnews/finalizers;feeds,verbs=update -// +kubebuilder:rbac:groups=newsaggregator.teamdev.com,resources=configmaps,verbs=get;list;watch -// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch - -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state -// -// This function will be called when a HotNews object is created, updated or deleted -// It will send a request to the news aggregator server to retrieve news with the parameters, -// specified in the HotNews object. -// Additionally, it is watching for changes in the ConfigMap with the feed groups, and in the Feed CRD. -// If there were any changes, it will also affect the HotNews object. -func (r *HotNewsReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - var hotNews newsaggregatorv1.HotNews - - err := r.Client.Get(ctx, req.NamespacedName, &hotNews) - if err != nil { - if k8sErrors.IsNotFound(err) { - return ctrl.Result{}, nil - } - logger.Error(err, "unable to fetch HotNews") - return ctrl.Result{}, nil - } - - if !controllerutil.ContainsFinalizer(&hotNews, newsaggregatorv1.HotNewsFinalizer) { - controllerutil.AddFinalizer(&hotNews, newsaggregatorv1.HotNewsFinalizer) - err := r.Client.Update(ctx, &hotNews) - if err != nil { - return ctrl.Result{}, err - } - } - - if !hotNews.DeletionTimestamp.IsZero() { - err = r.removeFeedReference(ctx, hotNews) - if err != nil { - return ctrl.Result{}, err - } - - controllerutil.RemoveFinalizer(&hotNews, newsaggregatorv1.HotNewsFinalizer) - err = r.Client.Update(ctx, &hotNews) - if err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil - } - - err = r.setFeedReference(ctx, hotNews) - if err != nil { - return ctrl.Result{}, err - } - - err = r.processHotNews(ctx, &hotNews) - if err != nil { - return ctrl.Result{}, err - } - logger.Info("HotNews object has been updated") - - err = r.Client.Status().Update(ctx, &hotNews) - if err != nil { - return ctrl.Result{}, err - } - - logger.Info("HotNewsReconciler has been successfully executed") - - return ctrl.Result{}, nil -} - -// SetupWithManager sets up the controller with the Manager, and initializes the k8s client -// to work with feedGroup Config Map. -// It Watches for any changes in the ConfigMap with the feed groups, and also watches for changes in Feed CRD. -func (r *HotNewsReconciler) SetupWithManager(mgr ctrl.Manager, serverUrl string) error { - r.serverUrl = serverUrl - return ctrl.NewControllerManagedBy(mgr). - For(&newsaggregatorv1.HotNews{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). - Watches( - &newsaggregatorv1.Feed{}, - &handler.EnqueueRequestForObject{}, - builder.WithPredicates(FeedStatusConditionPredicate{}), - ). - Complete(r) -} - -// articles struct is used to parse the response from the news aggregator server -type article struct { - Title string `json:"title" xml:"title"` - PubDate string `json:"publishedAt" xml:"pubDate"` - Description string `json:"description" xml:"description"` - Publisher string `xml:"source" json:"Publisher"` - Link string `json:"url" xml:"link"` -} - -// processHotNews function updates the HotNews object and returns an error if something goes wrong. -func (r *HotNewsReconciler) processHotNews(ctx context.Context, hotNews *newsaggregatorv1.HotNews) error { - logger := log.FromContext(ctx) - logger.Info("handling update") - - requestUrl, err := r.constructRequestUrl(ctx, hotNews.Spec) - if err != nil { - logger.Error(err, errFailedToConstructRequestUrl) - return err - } - logger.Info(requestUrl) - - req, err := http.NewRequest(http.MethodGet, requestUrl, nil) - if err != nil { - logger.Error(err, errFailedToCreateRequest) - return err - } - - customTransport := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - customClient := &http.Client{Transport: customTransport} - - res, err := customClient.Do(req) - if err != nil { - logger.Error(err, errFailedToSendRequest) - return err - } - - if res.StatusCode != http.StatusOK { - serverError := &serverErr{} - err = json.NewDecoder(res.Body).Decode(&serverError) - if err != nil { - logger.Error(err, errFailedToDecodeResBody) - return err - } - return serverError - } - - var articles struct { - TotalNews int `json:"totalAmount"` - News []article `json:"news"` - } - - err = json.NewDecoder(res.Body).Decode(&articles) - if err != nil { - logger.Error(err, errFailedToDecodeResBody) - return err - } - - err = res.Body.Close() - if err != nil { - logger.Error(err, errFailedToCloseResponseBody) - return err - } - - var articlesTitles []string - for _, a := range articles.News { - articlesTitles = append(articlesTitles, a.Title) - } - logger.Info("Total amount of news", "totalAmount", articles.TotalNews) - - hotNews.InitHotNewsStatus(articles.TotalNews, requestUrl, articlesTitles) - - logger.Info("HotNews.processHotNews has been successfully executed") - logger.Info("HotNews object", "HotNews", hotNews) - - return nil -} - -// constructRequestUrl function verifies if arguments are correct and constructs a request URL -// to our news aggregator server. -// -// Example: -// http://server.com/news?keywords=bitcoin&dateFrom=2024-08-05&dateEnd=2024-08-06&sources=abc,bbc -// http://server.com/news?keywords=bitcoin&dateFrom=2024-08-05&sources=abc,bbc -func (r *HotNewsReconciler) constructRequestUrl(ctx context.Context, spec newsaggregatorv1.HotNewsSpec) (string, error) { - var requestUrl strings.Builder - - requestUrl.WriteString(r.serverUrl) - var keywordsStr strings.Builder - for _, keyword := range spec.Keywords { - keywordsStr.WriteString(keyword) - keywordsStr.WriteRune(',') - } - requestUrl.WriteString("?keywords=" + keywordsStr.String()[:len(keywordsStr.String())-1]) - - var feedStr strings.Builder - if spec.FeedGroups != nil { - feedGroupsStr, err := r.processFeedGroups(spec) - if err != nil { - return "", err - } - feedStr.WriteString(feedGroupsStr) - } else { - feedsStr := r.processFeeds(spec) - feedStr.WriteString(feedsStr) - } - - requestUrl.WriteString("&sources=" + feedStr.String()) - - if spec.DateStart != "" { - requestUrl.WriteString("&dateFrom=" + spec.DateStart) - } - - if spec.DateEnd != "" { - requestUrl.WriteString("&dateEnd=" + spec.DateEnd) - } - - return requestUrl.String(), nil -} - -// setFeedReference sets the owner references for each Feed in the HotNewsSpec.Feeds array. -func (r *HotNewsReconciler) setFeedReference(ctx context.Context, hotNews newsaggregatorv1.HotNews) error { - if hotNews.Spec.FeedGroups != nil { - feedGroups, err := hotNews.GetFeedGroupNames(ctx) - if err != nil { - return err - } - - err = r.setOwnerReferenceForFeeds(ctx, hotNews, feedGroups) - if err != nil { - return err - } - } else { - err := r.setOwnerReferenceForFeeds(ctx, hotNews, hotNews.Spec.Feeds) - if err != nil { - return err - } - } - - return nil -} - -// setOwnerReferenceForFeeds sets the owner references for each Feed in the HotNewsSpec.Feeds array. -func (r *HotNewsReconciler) setOwnerReferenceForFeeds(ctx context.Context, hotNews newsaggregatorv1.HotNews, feeds []string) error { - var errList field.ErrorList - - ownerRef := metav1.NewControllerRef(&hotNews, newsaggregatorv1.GroupVersion.WithKind("HotNews")) - - for _, feedName := range feeds { - feed := &newsaggregatorv1.Feed{} - err := r.Client.Get(ctx, client.ObjectKey{ - Name: feedName, - Namespace: hotNews.Namespace, - }, feed) - if err != nil { - if k8sErrors.IsNotFound(err) { - errList = append(errList, field.NotFound(field.NewPath("feeds"), feedName)) - } else { - fmt.Println("error: failed to get Feed", err) - return err - } - continue - } - - if !hasOwnerReference(feed, ownerRef) { - feed.SetOwnerReferences(append(feed.GetOwnerReferences(), *ownerRef)) - } - - err = r.Update(ctx, feed) - if err != nil { - fmt.Println("error: failed to update Feed", err) - errList = append(errList, field.InternalError(field.NewPath("feeds").Child(feedName), err)) - } - } - - if len(errList) > 0 { - return errList.ToAggregate() - } - - return nil -} - -// hasOwnerReference checks if the given Feed already has the provided OwnerReference. -func hasOwnerReference(feed *newsaggregatorv1.Feed, ownerRef *metav1.OwnerReference) bool { - for _, ref := range feed.GetOwnerReferences() { - if ref.UID == ownerRef.UID { - return true - } - } - return false -} - -// removeFeedReference removes the owner references for each Feed in the HotNewsSpec.Feeds array. -func (r *HotNewsReconciler) removeFeedReference(ctx context.Context, hotNews newsaggregatorv1.HotNews) error { - if hotNews.Spec.FeedGroups != nil { - feedGroups, err := hotNews.GetFeedGroupNames(ctx) - if err != nil { - return err - } - - err = r.removeOwnerReferenceFromFeeds(ctx, &hotNews, feedGroups) - if err != nil { - return err - } - } else { - err := r.removeOwnerReferenceFromFeeds(ctx, &hotNews, hotNews.Spec.Feeds) - if err != nil { - return err - } - } - - return nil -} - -// removeOwnerReferenceFromFeeds removes the owner references for each Feed in the given feeds array -func (r *HotNewsReconciler) removeOwnerReferenceFromFeeds(ctx context.Context, hotNews *newsaggregatorv1.HotNews, feeds []string) error { - var errList field.ErrorList - - for _, feedName := range feeds { - feed := &newsaggregatorv1.Feed{} - err := r.Client.Get(ctx, client.ObjectKey{ - Namespace: hotNews.Namespace, - Name: feedName, - }, feed) - if err != nil { - if k8sErrors.IsNotFound(err) { - errList = append(errList, field.NotFound(field.NewPath("feeds"), feedName)) - } else { - return err - } - continue - } - - feed.SetOwnerReferences([]metav1.OwnerReference{}) - - err = r.Client.Update(ctx, feed) - if err != nil { - errList = append(errList, field.InternalError(field.NewPath("feeds"), err)) - } - } - - if errList != nil { - return errList.ToAggregate() - } - - return nil -} - -// processFeeds returns a string containing comma-separated feed sources -func (r *HotNewsReconciler) processFeeds(spec newsaggregatorv1.HotNewsSpec) string { - var sourcesBuilder strings.Builder - - for _, feed := range spec.Feeds { - sourcesBuilder.WriteString(feed) - sourcesBuilder.WriteRune(',') - } - - return sourcesBuilder.String()[:len(sourcesBuilder.String())-1] -} - -// processFeedGroups function processes feed groups from the ConfigMap and returns a string containing comma-separated -// feed sources -func (r *HotNewsReconciler) processFeedGroups(spec newsaggregatorv1.HotNewsSpec) (string, error) { - var sourcesBuilder strings.Builder - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - - configMaps, err := r.getFeedGroups(ctx) - if err != nil { - return "", err - } - - for _, feedGroup := range spec.FeedGroups { - for _, configMap := range configMaps.Items { - if _, ok := configMap.Data[feedGroup]; !ok { - return "", fmt.Errorf(errWrongFeedGroupName) - } else { - sourcesBuilder.WriteString(configMap.Data[feedGroup]) - sourcesBuilder.WriteRune(',') - } - } - } - - return sourcesBuilder.String()[:len(sourcesBuilder.String())-1], nil -} - -// getConfigMapData returns all data from config map named FeedGroupsConfigMapName in FeedGroupsNamespace -func (r *HotNewsReconciler) getFeedGroups(ctx context.Context) (v1.ConfigMapList, error) { - var configMaps v1.ConfigMapList - err := r.Client.List(ctx, &configMaps, client.InNamespace(newsaggregatorv1.FeedGroupsNamespace)) - - if err != nil { - return v1.ConfigMapList{}, err - } - - logger := log.FromContext(ctx) - for _, configMap := range configMaps.Items { - for key, item := range configMap.Data { - logger.Info("ConfigMap data", key, item) - } - } - - return configMaps, nil -} diff --git a/operator/internal/controller/hotnews_controller_test.go b/operator/internal/controller/hotnews_controller_test.go deleted file mode 100644 index f22a711..0000000 --- a/operator/internal/controller/hotnews_controller_test.go +++ /dev/null @@ -1,779 +0,0 @@ -/* -Copyright 2024. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "context" - "fmt" - "github.com/stretchr/testify/assert" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "net/http" - "net/http/httptest" - "reflect" - controllerruntime "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "testing" - - newsaggregatorv1 "teamdev.com/go-gator/api/v1" -) - -func TestHotNewsReconciler_Reconcile(t *testing.T) { - scheme := runtime.NewScheme() - _ = newsaggregatorv1.AddToScheme(scheme) - _ = v1.AddToScheme(scheme) - - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"totalAmount": 2, "news": [{"title": "News 1"}, {"title": "News 2"}]}`)) - })) - - existingHotNewsList := newsaggregatorv1.HotNews{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "feed-sample", - }, - Spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"keyword1,keyword2"}, - DateStart: "2024-08-12", - DateEnd: "2024-08-13", - Feeds: []string{"abc", "bbc"}, - }, - } - - existingConfigMap := v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: newsaggregatorv1.FeedGroupsNamespace, - Name: newsaggregatorv1.FeedGroupsConfigMapName, - }, - Data: map[string]string{ - "sport": "washingtontimes", - "politic": "abc,bbc", - }, - } - - k8sClient := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(&existingHotNewsList, &existingConfigMap). - Build() - - type fields struct { - Client client.Client - Scheme *runtime.Scheme - } - type args struct { - ctx context.Context - req controllerruntime.Request - } - - tests := []struct { - name string - fields fields - args args - want controllerruntime.Result - wantErr bool - }{ - { - name: "Successful Reconcile call", - fields: fields{ - Client: k8sClient, - Scheme: scheme, - }, - args: args{ - ctx: context.Background(), - req: controllerruntime.Request{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "feed-sample", - }, - }, - }, - want: controllerruntime.Result{}, - wantErr: false, - }, - { - name: "Failed because feed not found (invalid name)", - fields: fields{ - Client: k8sClient, - Scheme: scheme, - }, - args: args{ - ctx: context.Background(), - req: controllerruntime.Request{ - NamespacedName: types.NamespacedName{ - Namespace: "default", - Name: "feed-not-found", - }, - }, - }, - want: controllerruntime.Result{}, - wantErr: true, - }, - { - name: "Failed because feed not found (invalid namespace)", - fields: fields{ - Client: k8sClient, - Scheme: scheme, - }, - args: args{ - ctx: context.Background(), - req: controllerruntime.Request{ - NamespacedName: types.NamespacedName{ - Namespace: "non-existent-namespace", - Name: "feed-not-found", - }, - }, - }, - want: controllerruntime.Result{}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &HotNewsReconciler{ - Client: k8sClient, - Scheme: tt.fields.Scheme, - serverUrl: mockServer.URL, - } - - got, err := r.Reconcile(context.TODO(), tt.args.req) - if (err != nil) != tt.wantErr { - t.Errorf("Reconcile() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Reconcile() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHotNewsReconciler_constructRequestUrl(t *testing.T) { - scheme := runtime.NewScheme() - _ = newsaggregatorv1.AddToScheme(scheme) - _ = v1.AddToScheme(scheme) - serverNewsEndpoint := "https://go-gator-svc.go-gator.svc.cluster.local:443/news" - type fields struct { - Client client.Client - Scheme *runtime.Scheme - } - type args struct { - spec newsaggregatorv1.HotNewsSpec - } - tests := []struct { - name string - fields fields - args args - want string - wantErr bool - }{ - { - name: "Valid request with keywords, feeds, and date range", - fields: fields{}, - args: args{ - spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - Feeds: []string{"abc", "bbc"}, - DateStart: "2024-08-05", - DateEnd: "2024-08-06", - }, - }, - want: serverNewsEndpoint + "?keywords=bitcoin&sources=abc,bbc&dateFrom=2024-08-05&dateEnd=2024-08-06", - wantErr: false, - }, - { - name: "Valid request with keywords, feeds, and start date only", - fields: fields{}, - args: args{ - spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - Feeds: []string{"abc", "bbc"}, - DateStart: "2024-08-05", - }, - }, - want: serverNewsEndpoint + "?keywords=bitcoin&sources=abc,bbc&dateFrom=2024-08-05", - wantErr: false, - }, - { - name: "Valid request with keywords, feedGroups, and start date only", - fields: fields{ - Client: fake.NewClientBuilder().WithScheme(scheme). - WithObjects(&v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: newsaggregatorv1.FeedGroupsNamespace, - Name: newsaggregatorv1.FeedGroupsConfigMapName, - }, - Data: map[string]string{"sport": "abc,bbc"}, - }). - Build(), - }, - args: args{ - spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - FeedGroups: []string{"sport"}, - DateStart: "2024-08-05", - }, - }, - want: serverNewsEndpoint + "?keywords=bitcoin&sources=abc,bbc&dateFrom=2024-08-05", - wantErr: false, - }, - { - name: "Valid request with keywords and feeds only", - fields: fields{}, - args: args{ - spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - Feeds: []string{"abc", "bbc"}, - }, - }, - want: serverNewsEndpoint + "?keywords=bitcoin&sources=abc,bbc", - wantErr: false, - }, - { - name: "Invalid request because of feed groups", - fields: fields{ - Client: fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(&v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: newsaggregatorv1.FeedGroupsNamespace, - Name: newsaggregatorv1.FeedGroupsConfigMapName, - }, - Data: map[string]string{"sport": "aaaa"}, - }). - Build(), - }, - args: args{ - spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - FeedGroups: []string{"non-existent"}, - }, - }, - want: "", - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &HotNewsReconciler{ - Client: tt.fields.Client, - Scheme: tt.fields.Scheme, - serverUrl: serverNewsEndpoint, - } - got, err := r.constructRequestUrl(context.Background(), tt.args.spec) - - if tt.wantErr { - assert.NotNil(t, err) - } else { - assert.Nil(t, err) - } - assert.Equal(t, tt.want, got) - }) - } -} - -func TestHotNewsReconciler_getFeedGroups(t *testing.T) { - scheme := runtime.NewScheme() - _ = newsaggregatorv1.AddToScheme(scheme) - _ = v1.AddToScheme(scheme) - - existingConfigMap := v1.ConfigMapList{ - Items: []v1.ConfigMap{ - { - ObjectMeta: metav1.ObjectMeta{ - Namespace: newsaggregatorv1.FeedGroupsNamespace, - Name: newsaggregatorv1.FeedGroupsConfigMapName, - }, - Data: map[string]string{ - "sport": "washingtontimes", - "politic": "abc,bbc", - }, - }, - }, - } - - fakeClient := fake.NewClientBuilder().WithScheme(scheme). - WithLists(&existingConfigMap). - Build() - - errorClient := &errorReturningClient{ - Client: fakeClient, - } - - type fields struct { - Client client.Client - Scheme *runtime.Scheme - } - type args struct { - ctx context.Context - } - tests := []struct { - name string - fields fields - args args - want v1.ConfigMapList - wantErr bool - setup func() - }{ - { - name: "Successful retrieval of ConfigMap", - fields: fields{ - Client: fakeClient, - Scheme: scheme, - }, - args: args{ - ctx: context.TODO(), - }, - want: existingConfigMap, - wantErr: false, - setup: func() { - }, - }, - { - name: "Error during listing of ConfigMaps", - fields: fields{ - Client: errorClient, - Scheme: scheme, - }, - args: args{ - ctx: context.TODO(), - }, - want: v1.ConfigMapList{}, - wantErr: true, - setup: func() { - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.setup != nil { - tt.setup() - } - - r := &HotNewsReconciler{ - Client: tt.fields.Client, - Scheme: tt.fields.Scheme, - } - - got, err := r.getFeedGroups(context.Background()) - if (err != nil) != tt.wantErr { - t.Errorf("getFeedGroups() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("getFeedGroups() got = %v, want %v", got, tt.want) - } - }) - } -} - -// Custom client that returns an error during the List operation -type errorReturningClient struct { - client.Client -} - -func (c *errorReturningClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { - return fmt.Errorf("simulated error during listing of ConfigMaps") -} - -func TestHotNewsReconciler_processHotNews(t *testing.T) { - serverNewsEndpoint := "https://go-gator-svc.go-gator.svc.cluster.local:443/news" - scheme := runtime.NewScheme() - _ = newsaggregatorv1.AddToScheme(scheme) - _ = v1.AddToScheme(scheme) - - existingFeedList := &newsaggregatorv1.HotNewsList{ - Items: []newsaggregatorv1.HotNews{ - { - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "feed-sample", - }, - Spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"keyword1,keyword2"}, - DateStart: "2024-08-12", - DateEnd: "2024-08-13", - }, - }, - }, - } - - existingConfigMap := v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: newsaggregatorv1.FeedGroupsNamespace, - Name: newsaggregatorv1.FeedGroupsConfigMapName, - }, - Data: map[string]string{ - "sport": "washingtontimes", - "politic": "abc,bbc", - }, - } - - fakeClient := fake.NewClientBuilder().WithScheme(scheme). - WithLists(existingFeedList). - WithObjects(&existingConfigMap). - Build() - - type fields struct { - Client client.Client - Scheme *runtime.Scheme - } - type args struct { - ctx context.Context - hotNews *newsaggregatorv1.HotNews - } - tests := []struct { - name string - fields fields - args args - mockServer *httptest.Server - wantErr bool - }{ - { - name: "Successful execution", - fields: fields{ - Client: fakeClient, - Scheme: scheme, - }, - args: args{ - ctx: context.TODO(), - hotNews: &newsaggregatorv1.HotNews{ - Spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - DateStart: "2024-08-05", - DateEnd: "2024-08-06", - Feeds: []string{"abc", "bbc"}, - }, - }, - }, - mockServer: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"totalAmount": 2, "news": [{"title": "News 1"}, {"title": "News 2"}]}`)) - })), - wantErr: false, - }, - { - name: "Failed to construct request URL", - fields: fields{ - Client: fakeClient, - Scheme: scheme, - }, - args: args{ - ctx: context.TODO(), - hotNews: &newsaggregatorv1.HotNews{ - Spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - FeedGroups: []string{"non-existent feed group"}, - }, - }, - }, - wantErr: true, - }, - { - name: "Failed to create HTTP request", - fields: fields{ - Client: fakeClient, - Scheme: scheme, - }, - args: args{ - ctx: context.TODO(), - hotNews: &newsaggregatorv1.HotNews{ - Spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - DateStart: "2024-08-05", - DateEnd: "2024-08-06", - Feeds: []string{"abc", "bbc"}, - }, - }, - }, - mockServer: nil, - wantErr: true, - }, - { - name: "HTTP request failed", - fields: fields{ - Client: fakeClient, - Scheme: scheme, - }, - args: args{ - ctx: context.TODO(), - hotNews: &newsaggregatorv1.HotNews{ - Spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - DateStart: "2024-08-05", - DateEnd: "2024-08-06", - Feeds: []string{"abc", "bbc"}, - }, - }, - }, - mockServer: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - })), - wantErr: true, - }, - { - name: "Invalid response body JSON", - fields: fields{ - Client: fakeClient, - Scheme: scheme, - }, - args: args{ - ctx: context.TODO(), - hotNews: &newsaggregatorv1.HotNews{ - Spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"bitcoin"}, - DateStart: "2024-08-05", - DateEnd: "2024-08-06", - Feeds: []string{"abc", "bbc"}, - }, - }, - }, - mockServer: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{invalid json}`)) - })), - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.mockServer != nil { - defer tt.mockServer.Close() - serverNewsEndpoint = tt.mockServer.URL - } - - r := &HotNewsReconciler{ - Client: tt.fields.Client, - Scheme: tt.fields.Scheme, - serverUrl: serverNewsEndpoint, - } - if err := r.processHotNews(tt.args.ctx, tt.args.hotNews); (err != nil) != tt.wantErr { - t.Errorf("processHotNews() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestHotNewsReconciler_processFeedGroups(t *testing.T) { - scheme := runtime.NewScheme() - _ = newsaggregatorv1.AddToScheme(scheme) - _ = v1.AddToScheme(scheme) - - existingFeedList := &newsaggregatorv1.HotNewsList{ - Items: []newsaggregatorv1.HotNews{ - { - ObjectMeta: metav1.ObjectMeta{ - Namespace: "default", - Name: "feed-sample", - }, - Spec: newsaggregatorv1.HotNewsSpec{ - Keywords: []string{"keyword1,keyword2"}, - DateStart: "2024-08-12", - DateEnd: "2024-08-13", - }, - }, - }, - } - - existingConfigMap := v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: newsaggregatorv1.FeedGroupsNamespace, - Name: newsaggregatorv1.FeedGroupsConfigMapName, - }, - Data: map[string]string{ - "sport": "washingtontimes", - "politic": "abc,bbc", - }, - } - - fakeClient := fake.NewClientBuilder().WithScheme(scheme). - WithLists(existingFeedList). - WithObjects(&existingConfigMap). - Build() - - type fields struct { - Client client.Client - Scheme *runtime.Scheme - } - type args struct { - spec newsaggregatorv1.HotNewsSpec - } - tests := []struct { - name string - fields fields - args args - setup func() *v1.ConfigMap - want string - wantErr bool - }{ - { - name: "Successful processing with valid feed groups", - fields: fields{ - Client: fakeClient, - Scheme: scheme, - }, - args: args{ - spec: newsaggregatorv1.HotNewsSpec{ - FeedGroups: []string{"sport", "politic"}, - }, - }, - setup: func() *v1.ConfigMap { - return &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: newsaggregatorv1.FeedGroupsConfigMapName, - Namespace: newsaggregatorv1.FeedGroupsNamespace, - }, - Data: map[string]string{ - "sport": "washingtontimes", - "politic": "abc,bbc", - }, - } - }, - want: "washingtontimes,abc,bbc", - wantErr: false, - }, - { - name: "Config map is not registered in schema", - fields: fields{ - Client: fake.NewClientBuilder().WithScheme(runtime.NewScheme()).Build(), - Scheme: scheme, - }, - args: args{ - spec: newsaggregatorv1.HotNewsSpec{ - FeedGroups: []string{"nonexistent"}, - }, - }, - setup: nil, - want: "", - wantErr: true, - }, - { - name: "Feed group not found in ConfigMap", - fields: fields{ - Client: fakeClient, - Scheme: scheme, - }, - args: args{ - spec: newsaggregatorv1.HotNewsSpec{ - FeedGroups: []string{"nonexistent"}, - }, - }, - setup: func() *v1.ConfigMap { - return &v1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: newsaggregatorv1.FeedGroupsConfigMapName, - Namespace: newsaggregatorv1.FeedGroupsNamespace, - }, - Data: map[string]string{ - "sport": "washingtontimes", - }, - } - }, - want: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.setup != nil { - configMap := tt.setup() - if configMap != nil { - tt.fields.Client = fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(configMap). - Build() - } - } - - r := &HotNewsReconciler{ - Client: tt.fields.Client, - Scheme: tt.fields.Scheme, - } - got, err := r.processFeedGroups(tt.args.spec) - if (err != nil) != tt.wantErr { - t.Errorf("processFeedGroups() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("processFeedGroups() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHotNewsReconciler_SetupWithManager(t *testing.T) { - schema := runtime.NewScheme() - assert.Nil(t, newsaggregatorv1.AddToScheme(schema)) - assert.Nil(t, v1.AddToScheme(schema)) - - mgr, err := controllerruntime.NewManager(controllerruntime.GetConfigOrDie(), controllerruntime.Options{ - Scheme: schema, - }) - assert.Nil(t, err) - - type fields struct { - serverUrl string - Client client.Client - Scheme *runtime.Scheme - } - type args struct { - mgr controllerruntime.Manager - serverUrl string - } - tests := []struct { - name string - fields fields - args args - wantErr bool - }{ - { - name: "Successful setup", - fields: fields{ - serverUrl: "", - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }, - args: args{ - mgr: mgr, - serverUrl: "https://go-gator-svc.go-gator.svc.cluster.local:443/news", - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &HotNewsReconciler{ - serverUrl: tt.fields.serverUrl, - Client: tt.fields.Client, - Scheme: tt.fields.Scheme, - } - err := r.SetupWithManager(tt.args.mgr, tt.args.serverUrl) - if tt.wantErr { - assert.NotNil(t, err) - } else { - assert.Nil(t, err) - } - - assert.Equal(t, tt.args.serverUrl, r.serverUrl) - }) - } -} diff --git a/operator/internal/controller/hotnews_predicates.go b/operator/internal/controller/hotnews_predicates.go deleted file mode 100644 index 3d595de..0000000 --- a/operator/internal/controller/hotnews_predicates.go +++ /dev/null @@ -1,30 +0,0 @@ -package controller - -import ( - "sigs.k8s.io/controller-runtime/pkg/event" - "sigs.k8s.io/controller-runtime/pkg/predicate" - newsaggregatorv1 "teamdev.com/go-gator/api/v1" -) - -type FeedStatusConditionPredicate struct { - predicate.Funcs -} - -func (FeedStatusConditionPredicate) Update(e event.UpdateEvent) bool { - if e.ObjectNew == nil { - return false - } - feed, ok := e.ObjectNew.(*newsaggregatorv1.Feed) - if !ok { - return false - } - - if condition, exists := feed.Status.Conditions["Created"]; exists && condition.Status { - return true - } - if condition, exists := feed.Status.Conditions["Deleted"]; exists && condition.Status { - return true - } - - return false -} diff --git a/operator/internal/controller/hotnews_predicates_test.go b/operator/internal/controller/hotnews_predicates_test.go deleted file mode 100644 index 14fac98..0000000 --- a/operator/internal/controller/hotnews_predicates_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package controller - -import ( - "github.com/stretchr/testify/assert" - "sigs.k8s.io/controller-runtime/pkg/event" - newsaggregatorv1 "teamdev.com/go-gator/api/v1" - "testing" -) - -func TestFeedStatusConditionPredicate_Update(t *testing.T) { - predicate := FeedStatusConditionPredicate{} - - tests := []struct { - name string - e event.UpdateEvent - expected bool - }{ - { - name: "ObjectNew is nil", - e: event.UpdateEvent{ObjectNew: nil}, - expected: false, - }, - { - name: "ObjectNew is *newsaggregatorv1.Feed with Conditions['Created'] true", - e: event.UpdateEvent{ObjectNew: &newsaggregatorv1.Feed{ - Status: newsaggregatorv1.FeedStatus{ - Conditions: map[string]newsaggregatorv1.FeedConditions{ - "Created": {Status: true}, - }, - }, - }}, - expected: true, - }, - { - name: "ObjectNew is *newsaggregatorv1.Feed with Conditions['Deleted'] true", - e: event.UpdateEvent{ObjectNew: &newsaggregatorv1.Feed{ - Status: newsaggregatorv1.FeedStatus{ - Conditions: map[string]newsaggregatorv1.FeedConditions{ - "Deleted": {Status: true}, - }, - }, - }}, - expected: true, - }, - { - name: "ObjectNew is *newsaggregatorv1.Feed but neither Conditions['Created'] nor Conditions['Deleted'] are true", - e: event.UpdateEvent{ObjectNew: &newsaggregatorv1.Feed{ - Status: newsaggregatorv1.FeedStatus{ - Conditions: map[string]newsaggregatorv1.FeedConditions{ - "Created": {Status: false}, - "Deleted": {Status: false}, - }, - }, - }}, - expected: false, - }, - { - name: "ObjectNew is not of type *newsaggregatorv1.Feed", - e: event.UpdateEvent{ObjectNew: &newsaggregatorv1.HotNews{}}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := predicate.Update(tt.e) - assert.Equal(t, tt.expected, result) - }) - } -}