Repository: clusternet/clusternet
Pull request #92 (2021-08-30 09:37:54)

prune redundant labels on HelmChart and Manifest
<!--  Thanks for sending a pull request!

-->

#### What type of PR is this?
kind/enhancement

#### What this PR does / why we need it:

#### Which issue(s) this PR fixes:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #

#### Special notes for your reviewer:


codecov-commenter: # [Codecov](https://codecov.io/gh/clusternet/clusternet/pull/92?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet) Report
> Merging [#92](https://codecov.io/gh/clusternet/clusternet/pull/92?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet) (8f7fa73) into [main](https://codecov.io/gh/clusternet/clusternet/commit/f3950040e4287879e2ad2bbfd5e84339c9f8fbea?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet) (f395004) will **increase** coverage by `0.32%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/clusternet/clusternet/pull/92/graphs/tree.svg?width=650&height=150&src=pr&token=4O6PTR0RJN&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet)](https://codecov.io/gh/clusternet/clusternet/pull/92?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet)

```diff
@@           Coverage Diff            @@
##            main     #92      +/-   ##
========================================
+ Coverage   7.37%   7.69%   +0.32%     
========================================
  Files         29      29              
  Lines       3757    3782      +25     
========================================
+ Hits         277     291      +14     
- Misses      3458    3468      +10     
- Partials      22      23       +1     
```


| [Impacted Files](https://codecov.io/gh/clusternet/clusternet/pull/92?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet) | Coverage Δ | |
|---|---|---|
| [...ernet/clusternet/pkg/hub/apiserver/shadow/utils.go](https://codecov.io/gh/clusternet/clusternet/pull/92/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet#diff-c3JjL2dpdGh1Yi5jb20vY2x1c3Rlcm5ldC9jbHVzdGVybmV0L3BrZy9odWIvYXBpc2VydmVyL3NoYWRvdy91dGlscy5nbw==) | `89.28% <0.00%> (-10.72%)` | :arrow_down: |
| [...ub.com/clusternet/clusternet/pkg/utils/deployer.go](https://codecov.io/gh/clusternet/clusternet/pull/92/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet#diff-c3JjL2dpdGh1Yi5jb20vY2x1c3Rlcm5ldC9jbHVzdGVybmV0L3BrZy91dGlscy9kZXBsb3llci5nbw==) | `0.00% <0.00%> (ø)` | |
| [...github.com/clusternet/clusternet/pkg/utils/feed.go](https://codecov.io/gh/clusternet/clusternet/pull/92/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet#diff-c3JjL2dpdGh1Yi5jb20vY2x1c3Rlcm5ldC9jbHVzdGVybmV0L3BrZy91dGlscy9mZWVkLmdv) | `25.27% <0.00%> (+15.75%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/clusternet/clusternet/pull/92?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/clusternet/clusternet/pull/92?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet). Last update [f395004...8f7fa73](https://codecov.io/gh/clusternet/clusternet/pull/92?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=clusternet).


Patch:
diff --git a/pkg/controllers/apps/helmchart/helmchart.go b/pkg/controllers/apps/helmchart/helmchart.go
index 6ea4cb8..508fbe1 100644
--- a/pkg/controllers/apps/helmchart/helmchart.go
+++ b/pkg/controllers/apps/helmchart/helmchart.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"fmt"
 	"reflect"
+	"strings"
 	"time"
 
 	corev1 "k8s.io/api/core/v1"
@@ -59,6 +60,8 @@ type Controller struct {
 
 	helmChartLister applisters.HelmChartLister
 	helmChartSynced cache.InformerSynced
+	baseLister      applisters.BaseLister
+	baseSynced      cache.InformerSynced
 
 	feedInUseProtection bool
 
@@ -67,8 +70,8 @@ type Controller struct {
 }
 
 func NewController(clusternetClient clusternetclientset.Interface,
-	helmChartInformer appinformers.HelmChartInformer, feedInUseProtection bool,
-	recorder record.EventRecorder, syncHandlerFunc SyncHandlerFunc) (*Controller, error) {
+	helmChartInformer appinformers.HelmChartInformer, baseInformer appinformers.BaseInformer,
+	feedInUseProtection bool, recorder record.EventRecorder, syncHandlerFunc SyncHandlerFunc) (*Controller, error) {
 	if syncHandlerFunc == nil {
 		return nil, fmt.Errorf("syncHandlerFunc must be set")
 	}
@@ -78,6 +81,8 @@ func NewController(clusternetClient clusternetclientset.Interface,
 		workqueue:           workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "helmChart"),
 		helmChartLister:     helmChartInformer.Lister(),
 		helmChartSynced:     helmChartInformer.Informer().HasSynced,
+		baseLister:          baseInformer.Lister(),
+		baseSynced:          baseInformer.Informer().HasSynced,
 		feedInUseProtection: feedInUseProtection,
 		recorder:            recorder,
 		syncHandlerFunc:     syncHandlerFunc,
@@ -90,6 +95,10 @@ func NewController(clusternetClient clusternetclientset.Interface,
 		DeleteFunc: c.deleteHelmChart,
 	})
 
+	baseInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: c.updateBase,
+	})
+
 	return c, nil
 }
 
@@ -105,7 +114,7 @@ func (c *Controller) Run(workers int, stopCh <-chan struct{}) {
 	defer klog.Info("shutting down helmchart controller")
 
 	// Wait for the caches to be synced before starting workers
-	if !cache.WaitForNamedCacheSync("helmchart-controller", stopCh, c.helmChartSynced) {
+	if !cache.WaitForNamedCacheSync("helmchart-controller", stopCh, c.helmChartSynced, c.baseSynced) {
 		return
 	}
 
@@ -161,6 +170,30 @@ func (c *Controller) deleteHelmChart(obj interface{}) {
 	c.enqueue(chart)
 }
 
+func (c *Controller) updateBase(old, cur interface{}) {
+	oldBase := old.(*appsapi.Base)
+	newBase := cur.(*appsapi.Base)
+
+	if newBase.DeletionTimestamp != nil {
+		// labels pruning had already been done when a Base got deleted.
+		return
+	}
+
+	// Decide whether discovery has reported a spec change.
+	if reflect.DeepEqual(oldBase.Spec.Feeds, newBase.Spec.Feeds) {
+		return
+	}
+
+	for _, feed := range utils.FindObsoletedFeeds(oldBase.Spec.Feeds, newBase.Spec.Feeds) {
+		if feed.Kind != "HelmChart" {
+			continue
+		}
+		namespacedKey := feed.Namespace + "/" + feed.Name
+		klog.V(6).Infof("pruning labels of HelmChart %q", namespacedKey)
+		c.workqueue.Add(namespacedKey)
+	}
+}
+
 // runWorker is a long-running function that will continually call the
 // processNextWorkItem function in order to read and process a message on the
 // workqueue.
@@ -272,6 +305,9 @@ func (c *Controller) syncHandler(key string) error {
 		updatedChart.Labels[known.ConfigNameLabel] = chart.Name
 		updatedChart.Labels[known.ConfigNamespaceLabel] = chart.Namespace
 
+		// prune redundant labels
+		pruneLabels(updatedChart, c.baseLister)
+
 		// only update on changed
 		if !reflect.DeepEqual(chart, updatedChart) {
 			if chart, err = c.clusternetClient.AppsV1alpha1().HelmCharts(chart.Namespace).Update(context.TODO(),
@@ -334,3 +370,30 @@ func (c *Controller) enqueue(chart *appsapi.HelmChart) {
 	}
 	c.workqueue.Add(key)
 }
+
+func pruneLabels(chart *appsapi.HelmChart, baseLister applisters.BaseLister) {
+	// find all Bases
+	baseUIDs := []string{}
+	for key, val := range chart.Labels {
+		// normally the length of a uuid is 36
+		if len(key) != 36 || strings.Contains(key, "/") || val != "Base" {
+			continue
+		}
+		baseUIDs = append(baseUIDs, key)
+	}
+
+	for _, base := range utils.FindBasesFromUIDs(baseLister, baseUIDs) {
+		if utils.HasFeed(appsapi.Feed{
+			Kind:      controllerKind.Kind,
+			Namespace: chart.Namespace,
+			Name:      chart.Name,
+		}, base.Spec.Feeds) {
+			continue
+		}
+
+		// prune labels
+		// since Base is not referring this HelmChart anymore
+		delete(chart.Labels, string(base.UID))
+		delete(chart.Labels, base.Labels[known.ConfigUIDLabel]) // Subscription
+	}
+}
diff --git a/pkg/controllers/apps/manifest/manifest.go b/pkg/controllers/apps/manifest/manifest.go
index 2e33910..ac991c6 100644
--- a/pkg/controllers/apps/manifest/manifest.go
+++ b/pkg/controllers/apps/manifest/manifest.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"fmt"
 	"reflect"
+	"strings"
 	"time"
 
 	corev1 "k8s.io/api/core/v1"
@@ -58,6 +59,8 @@ type Controller struct {
 
 	manifestLister applisters.ManifestLister
 	manifestSynced cache.InformerSynced
+	baseLister     applisters.BaseLister
+	baseSynced     cache.InformerSynced
 
 	feedInUseProtection bool
 
@@ -66,8 +69,8 @@ type Controller struct {
 }
 
 func NewController(clusternetClient clusternetclientset.Interface,
-	manifestInformer appinformers.ManifestInformer, feedInUseProtection bool,
-	recorder record.EventRecorder, syncHandlerFunc SyncHandlerFunc) (*Controller, error) {
+	manifestInformer appinformers.ManifestInformer, baseInformer appinformers.BaseInformer,
+	feedInUseProtection bool, recorder record.EventRecorder, syncHandlerFunc SyncHandlerFunc) (*Controller, error) {
 	if syncHandlerFunc == nil {
 		return nil, fmt.Errorf("syncHandlerFunc must be set")
 	}
@@ -77,6 +80,8 @@ func NewController(clusternetClient clusternetclientset.Interface,
 		workqueue:           workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "manifest"),
 		manifestLister:      manifestInformer.Lister(),
 		manifestSynced:      manifestInformer.Informer().HasSynced,
+		baseLister:          baseInformer.Lister(),
+		baseSynced:          baseInformer.Informer().HasSynced,
 		feedInUseProtection: feedInUseProtection,
 		recorder:            recorder,
 		syncHandlerFunc:     syncHandlerFunc,
@@ -89,6 +94,10 @@ func NewController(clusternetClient clusternetclientset.Interface,
 		DeleteFunc: c.deleteManifest,
 	})
 
+	baseInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: c.updateBase,
+	})
+
 	return c, nil
 }
 
@@ -104,7 +113,7 @@ func (c *Controller) Run(workers int, stopCh <-chan struct{}) {
 	defer klog.Info("shutting down manifest controller")
 
 	// Wait for the caches to be synced before starting workers
-	if !cache.WaitForNamedCacheSync("manifest-controller", stopCh, c.manifestSynced) {
+	if !cache.WaitForNamedCacheSync("manifest-controller", stopCh, c.manifestSynced, c.baseSynced) {
 		return
 	}
 
@@ -160,6 +169,43 @@ func (c *Controller) deleteManifest(obj interface{}) {
 	c.enqueue(manifest)
 }
 
+func (c *Controller) updateBase(old, cur interface{}) {
+	oldBase := old.(*appsapi.Base)
+	newBase := cur.(*appsapi.Base)
+
+	if newBase.DeletionTimestamp != nil {
+		// labels pruning had already been done when a Base got deleted.
+		return
+	}
+
+	// Decide whether discovery has reported a spec change.
+	if reflect.DeepEqual(oldBase.Spec.Feeds, newBase.Spec.Feeds) {
+		return
+	}
+
+	klog.V(6).Info("waiting for Manifest caches to sync before pruning labels")
+	if !cache.WaitForCacheSync(context.TODO().Done(), c.manifestSynced) {
+		return
+	}
+
+	for _, feed := range utils.FindObsoletedFeeds(oldBase.Spec.Feeds, newBase.Spec.Feeds) {
+		if feed.Kind == "HelmChart" {
+			continue
+		}
+
+		manifests, err := utils.ListManifestsBySelector(c.manifestLister, feed)
+		if err != nil {
+			klog.ErrorDepth(6, err)
+			continue
+		}
+
+		for _, manifest := range manifests {
+			klog.V(6).Infof("pruning labels of Manifest %q", klog.KObj(manifest))
+			c.workqueue.Add(klog.KObj(manifest).String())
+		}
+	}
+}
+
 // runWorker is a long-running function that will continually call the
 // processNextWorkItem function in order to read and process a message on the
 // workqueue.
@@ -261,6 +307,9 @@ func (c *Controller) syncHandler(key string) error {
 			updatedManifest.Finalizers = append(updatedManifest.Finalizers, known.FeedProtectionFinalizer)
 		}
 
+		// prune redundant labels
+		pruneLabels(updatedManifest, c.baseLister)
+
 		// only update on changed
 		if !reflect.DeepEqual(manifest, updatedManifest) {
 			if manifest, err = c.clusternetClient.AppsV1alpha1().Manifests(updatedManifest.Namespace).Update(context.TODO(),
@@ -298,3 +347,30 @@ func (c *Controller) enqueue(manifest *appsapi.Manifest) {
 	}
 	c.workqueue.Add(key)
 }
+
+func pruneLabels(manifest *appsapi.Manifest, baseLister applisters.BaseLister) {
+	// find all Bases
+	baseUIDs := []string{}
+	for key, val := range manifest.Labels {
+		// normally the length of a uuid is 36
+		if len(key) != 36 || strings.Contains(key, "/") || val != "Base" {
+			continue
+		}
+		baseUIDs = append(baseUIDs, key)
+	}
+
+	for _, base := range utils.FindBasesFromUIDs(baseLister, baseUIDs) {
+		if utils.HasFeed(appsapi.Feed{
+			Kind:      manifest.Labels[known.ConfigKindLabel],
+			Namespace: manifest.Labels[known.ConfigNamespaceLabel],
+			Name:      manifest.Labels[known.ConfigNameLabel],
+		}, base.Spec.Feeds) {
+			continue
+		}
+
+		// prune labels
+		// since Base is not referring this resource anymore
+		delete(manifest.Labels, string(base.UID))
+		delete(manifest.Labels, base.Labels[known.ConfigUIDLabel]) // Subscription
+	}
+}
diff --git a/pkg/hub/deployer/deployer.go b/pkg/hub/deployer/deployer.go
index dacfc25..f84f8bf 100644
--- a/pkg/hub/deployer/deployer.go
+++ b/pkg/hub/deployer/deployer.go
@@ -130,6 +130,7 @@ func NewDeployer(apiserverURL string, kubeclient *kubernetes.Clientset, clustern
 
 	helmChartController, err := helmchart.NewController(clusternetclient,
 		clusternetInformerFactory.Apps().V1alpha1().HelmCharts(),
+		clusternetInformerFactory.Apps().V1alpha1().Bases(),
 		feedInUseProtection,
 		deployer.recorder, deployer.handleHelmChart)
 	if err != nil {
@@ -164,6 +165,7 @@ func NewDeployer(apiserverURL string, kubeclient *kubernetes.Clientset, clustern
 
 	mfstController, err := manifest.NewController(clusternetclient,
 		clusternetInformerFactory.Apps().V1alpha1().Manifests(),
+		clusternetInformerFactory.Apps().V1alpha1().Bases(),
 		feedInUseProtection,
 		deployer.recorder,
 		deployer.handleManifest)
diff --git a/pkg/utils/deployer.go b/pkg/utils/deployer.go
index 0f6e176..61d3937 100644
--- a/pkg/utils/deployer.go
+++ b/pkg/utils/deployer.go
@@ -365,10 +365,6 @@ func ApplyDescription(ctx context.Context, clusternetClient *clusternetclientset
 	if len(allErrs) > 0 {
 		return utilerrors.NewAggregate(allErrs)
 	}
-
-	if err != nil {
-		klog.ErrorDepth(5, err)
-	}
 	return err
 }
 
diff --git a/pkg/utils/feed.go b/pkg/utils/feed.go
index 9e1064d..5ecb5e0 100644
--- a/pkg/utils/feed.go
+++ b/pkg/utils/feed.go
@@ -26,6 +26,7 @@ import (
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/klog/v2"
 
 	appsapi "github.com/clusternet/clusternet/pkg/apis/apps/v1alpha1"
 	clusternetclientset "github.com/clusternet/clusternet/pkg/generated/clientset/versioned"
@@ -117,3 +118,44 @@ func RemoveFeedFromSubscription(ctx context.Context, clusternetClient *clusterne
 		"")
 	return err
 }
+
+func FindObsoletedFeeds(oldFeeds []appsapi.Feed, newFeeds []appsapi.Feed) []appsapi.Feed {
+	desiredFeedsMap := make(map[string]bool)
+	for _, feed := range newFeeds {
+		desiredFeedsMap[FormatFeed(feed)] = true
+	}
+
+	obsoleteFeeds := []appsapi.Feed{}
+	for _, feed := range oldFeeds {
+		if !desiredFeedsMap[FormatFeed(feed)] {
+			obsoleteFeeds = append(obsoleteFeeds, feed)
+		}
+	}
+	return obsoleteFeeds
+}
+
+func FindBasesFromUIDs(baseLister applisters.BaseLister, uids []string) []*appsapi.Base {
+	allBases := []*appsapi.Base{}
+	for _, uid := range uids {
+		bases, err := baseLister.List(labels.SelectorFromSet(labels.Set{
+			uid: "Base",
+		}))
+		if err != nil {
+			klog.ErrorDepth(5, err)
+			continue
+		}
+		allBases = append(allBases, bases...)
+	}
+
+	return allBases
+}
+
+func HasFeed(feed appsapi.Feed, feeds []appsapi.Feed) bool {
+	for _, f := range feeds {
+		if f.Kind == feed.Kind && f.Namespace == feed.Namespace && f.Name == feed.Name {
+			return true
+		}
+	}
+
+	return false
+}


Test patch:
diff --git a/pkg/utils/feed_test.go b/pkg/utils/feed_test.go
index 3d32931..6e1a74c 100644
--- a/pkg/utils/feed_test.go
+++ b/pkg/utils/feed_test.go
@@ -17,19 +17,20 @@ limitations under the License.
 package utils
 
 import (
+	"reflect"
 	"testing"
 
-	"github.com/clusternet/clusternet/pkg/apis/apps/v1alpha1"
+	appsapi "github.com/clusternet/clusternet/pkg/apis/apps/v1alpha1"
 )
 
 func TestFormatFeed(t *testing.T) {
 	tests := []struct {
 		name string
-		feed v1alpha1.Feed
+		feed appsapi.Feed
 		want string
 	}{
 		{
-			feed: v1alpha1.Feed{
+			feed: appsapi.Feed{
 				Kind:       "Guess",
 				APIVersion: "v1",
 				Namespace:  "",
@@ -38,7 +39,7 @@ func TestFormatFeed(t *testing.T) {
 			want: "Guess what",
 		},
 		{
-			feed: v1alpha1.Feed{
+			feed: appsapi.Feed{
 				Kind:       "Guess",
 				APIVersion: "v1",
 				Namespace:  "demo",
@@ -55,3 +56,206 @@ func TestFormatFeed(t *testing.T) {
 		})
 	}
 }
+
+func TestFindObsoletedFeeds(t *testing.T) {
+	tests := []struct {
+		name     string
+		oldFeeds []appsapi.Feed
+		newFeeds []appsapi.Feed
+		want     []appsapi.Feed
+	}{
+		{
+			name: "same feeds",
+			oldFeeds: []appsapi.Feed{
+				{
+					Kind:      "Abc",
+					Namespace: "foo",
+					Name:      "bar",
+				},
+				{
+					Kind:      "Def",
+					Namespace: "far",
+					Name:      "away",
+				},
+				{
+					Kind:      "Ghi",
+					Namespace: "demo",
+					Name:      "test",
+				},
+			},
+			newFeeds: []appsapi.Feed{
+				{
+					Kind:      "Abc",
+					Namespace: "foo",
+					Name:      "bar",
+				},
+				{
+					Kind:      "Def",
+					Namespace: "far",
+					Name:      "away",
+				},
+				{
+					Kind:      "Ghi",
+					Namespace: "demo",
+					Name:      "test",
+				},
+			},
+			want: []appsapi.Feed{},
+		},
+
+		{
+			name: "same feeds, but order differs",
+			oldFeeds: []appsapi.Feed{
+				{
+					Kind:      "Abc",
+					Namespace: "foo",
+					Name:      "bar",
+				},
+				{
+					Kind:      "Def",
+					Namespace: "far",
+					Name:      "away",
+				},
+				{
+					Kind:      "Ghi",
+					Namespace: "demo",
+					Name:      "test",
+				},
+			},
+			newFeeds: []appsapi.Feed{
+				{
+					Kind:      "Abc",
+					Namespace: "foo",
+					Name:      "bar",
+				},
+				{
+					Kind:      "Ghi",
+					Namespace: "demo",
+					Name:      "test",
+				},
+				{
+					Kind:      "Def",
+					Namespace: "far",
+					Name:      "away",
+				},
+			},
+			want: []appsapi.Feed{},
+		},
+
+		{
+			name: "feeds differed",
+			oldFeeds: []appsapi.Feed{
+				{
+					Kind:      "Abc",
+					Namespace: "foo",
+					Name:      "bar",
+				},
+				{
+					Kind:      "Def",
+					Namespace: "far",
+					Name:      "away",
+				},
+				{
+					Kind:      "Ghi",
+					Namespace: "demo",
+					Name:      "test",
+				},
+			},
+			newFeeds: []appsapi.Feed{
+				{
+					Kind:      "Abc",
+					Namespace: "foo",
+					Name:      "bar",
+				},
+				{
+					Kind:      "Ghi",
+					Namespace: "demo",
+					Name:      "test",
+				},
+				{
+					Kind:      "Jkl",
+					Namespace: "fly",
+					Name:      "high",
+				},
+			},
+			want: []appsapi.Feed{
+				{
+					Kind:      "Def",
+					Namespace: "far",
+					Name:      "away",
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := FindObsoletedFeeds(tt.oldFeeds, tt.newFeeds); !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("FindObsoletedFeeds() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestHasFeed(t *testing.T) {
+	tests := []struct {
+		name  string
+		feed  appsapi.Feed
+		feeds []appsapi.Feed
+		want  bool
+	}{
+		{
+			name: "not found",
+			feed: appsapi.Feed{
+				Kind:      "ABC",
+				Namespace: "def",
+				Name:      "foo",
+			},
+			feeds: []appsapi.Feed{
+				{
+					Kind:       "DEF",
+					APIVersion: "v1",
+					Namespace:  "def",
+					Name:       "foo",
+				},
+				{
+					Kind:       "GHI",
+					APIVersion: "v1",
+					Namespace:  "def",
+					Name:       "far",
+				},
+			},
+			want: false,
+		},
+
+		{
+			name: "found",
+			feed: appsapi.Feed{
+				Kind:      "ABC",
+				Namespace: "def",
+				Name:      "foo",
+			},
+			feeds: []appsapi.Feed{
+				{
+					Kind:       "DEF",
+					APIVersion: "v1",
+					Namespace:  "def",
+					Name:       "foo",
+				},
+				{
+					Kind:       "ABC",
+					APIVersion: "v1",
+					Namespace:  "def",
+					Name:       "foo",
+				},
+			},
+			want: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := HasFeed(tt.feed, tt.feeds); got != tt.want {
+				t.Errorf("HasFeed() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}