Repository: rancher/fleet
Pull request #3033 (2024-10-30 11:27:38)

[0.10] Backport GitRepo status resources improvements
Refers to #3036

* Partial backport of #2997
* Small changes to ease backporting
* Backport #3027


Patch:
diff --git a/internal/cmd/controller/grutil/gitjob.go b/internal/cmd/controller/gitops/reconciler/gitjob.go
similarity index 85%
rename from internal/cmd/controller/grutil/gitjob.go
rename to internal/cmd/controller/gitops/reconciler/gitjob.go
index 17efeb8f..1ffe0f4d 100644
--- a/internal/cmd/controller/grutil/gitjob.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob.go
@@ -1,6 +1,6 @@
 // Copyright (c) 2021-2023 SUSE LLC
 
-package grutil
+package reconciler
 
 import (
 	corev1 "k8s.io/api/core/v1"
@@ -8,7 +8,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
-func NewServiceAccount(namespace string, name string) *corev1.ServiceAccount {
+func newServiceAccount(namespace string, name string) *corev1.ServiceAccount {
 	return &corev1.ServiceAccount{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      name,
@@ -17,7 +17,7 @@ func NewServiceAccount(namespace string, name string) *corev1.ServiceAccount {
 	}
 }
 
-func NewRole(namespace string, name string) *rbacv1.Role {
+func newRole(namespace string, name string) *rbacv1.Role {
 	return &rbacv1.Role{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      name,
@@ -43,7 +43,7 @@ func NewRole(namespace string, name string) *rbacv1.Role {
 	}
 }
 
-func NewRoleBinding(namespace string, name string) *rbacv1.RoleBinding {
+func newRoleBinding(namespace string, name string) *rbacv1.RoleBinding {
 	return &rbacv1.RoleBinding{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:      name,
diff --git a/internal/cmd/controller/gitops/reconciler/gitjob_controller.go b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
index 24b08554..10873506 100644
--- a/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
@@ -14,7 +14,6 @@ import (
 	"github.com/reugn/go-quartz/quartz"
 
 	"github.com/rancher/fleet/internal/cmd/controller/finalize"
-	"github.com/rancher/fleet/internal/cmd/controller/grutil"
 	"github.com/rancher/fleet/internal/cmd/controller/imagescan"
 	"github.com/rancher/fleet/internal/metrics"
 	"github.com/rancher/fleet/internal/ociwrapper"
@@ -151,10 +150,10 @@ func (r *GitJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 
 	// Restrictions / Overrides, gitrepo reconciler is responsible for setting error in status
 	oldStatus := gitrepo.Status.DeepCopy()
-	_, err := grutil.AuthorizeAndAssignDefaults(ctx, r.Client, gitrepo)
+	_, err := authorizeAndAssignDefaults(ctx, r.Client, gitrepo)
 	if err != nil {
 		r.Recorder.Event(gitrepo, fleetevent.Warning, "FailedToApplyRestrictions", err.Error())
-		return ctrl.Result{}, grutil.UpdateErrorStatus(ctx, r.Client, req.NamespacedName, *oldStatus, err)
+		return ctrl.Result{}, updateErrorStatus(ctx, r.Client, req.NamespacedName, *oldStatus, err)
 	}
 
 	if !gitrepo.DeletionTimestamp.IsZero() {
@@ -207,29 +206,27 @@ func (r *GitJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		return res, err
 	}
 
-	err = grutil.SetStatusFromBundleDeployments(ctx, r.Client, gitrepo)
+	err = SetStatusFromBundleDeployments(ctx, r.Client, gitrepo)
 	if err != nil {
-		return result(repoPolled, gitrepo), grutil.UpdateErrorStatus(ctx, r.Client, req.NamespacedName, gitrepo.Status, err)
+		return result(repoPolled, gitrepo), updateErrorStatus(ctx, r.Client, req.NamespacedName, gitrepo.Status, err)
 	}
 
-	err = grutil.SetStatusFromBundles(ctx, r.Client, gitrepo)
+	err = SetStatusFromBundles(ctx, r.Client, gitrepo)
 	if err != nil {
-		return result(repoPolled, gitrepo), grutil.UpdateErrorStatus(ctx, r.Client, req.NamespacedName, gitrepo.Status, err)
+		return result(repoPolled, gitrepo), updateErrorStatus(ctx, r.Client, req.NamespacedName, gitrepo.Status, err)
 	}
 
-	if err = grutil.UpdateDisplayState(gitrepo); err != nil {
-		return result(repoPolled, gitrepo), grutil.UpdateErrorStatus(ctx, r.Client, req.NamespacedName, gitrepo.Status, err)
+	if err = UpdateDisplayState(gitrepo); err != nil {
+		return result(repoPolled, gitrepo), updateErrorStatus(ctx, r.Client, req.NamespacedName, gitrepo.Status, err)
 	}
 
-	grutil.SetStatusFromResourceKey(ctx, r.Client, gitrepo)
-
 	gitrepo.Status.Display.ReadyBundleDeployments = fmt.Sprintf("%d/%d",
 		gitrepo.Status.Summary.Ready,
 		gitrepo.Status.Summary.DesiredReady)
 
-	grutil.SetCondition(&gitrepo.Status, nil)
+	SetCondition(&gitrepo.Status, nil)
 
-	err = grutil.UpdateStatus(ctx, r.Client, req.NamespacedName, gitrepo.Status)
+	err = updateStatus(ctx, r.Client, req.NamespacedName, gitrepo.Status)
 	if err != nil {
 		logger.Error(err, "Reconcile failed final update to git repo status", "status", gitrepo.Status)
 
@@ -273,7 +270,7 @@ func (r *GitJobReconciler) manageGitJob(ctx context.Context, logger logr.Logger,
 			r.updateGenerationValuesIfNeeded(gitrepo)
 			if err := r.validateExternalSecretExist(ctx, gitrepo); err != nil {
 				r.Recorder.Event(gitrepo, fleetevent.Warning, "FailedValidatingSecret", err.Error())
-				return result(repoPolled, gitrepo), grutil.UpdateErrorStatus(ctx, r.Client, name, gitrepo.Status, err)
+				return result(repoPolled, gitrepo), updateErrorStatus(ctx, r.Client, name, gitrepo.Status, err)
 			}
 			if err := r.createJobAndResources(ctx, gitrepo, logger); err != nil {
 				return result(repoPolled, gitrepo), err
@@ -293,8 +290,8 @@ func (r *GitJobReconciler) manageGitJob(ctx context.Context, logger logr.Logger,
 
 	gitrepo.Status.ObservedGeneration = gitrepo.Generation
 
-	if err = grutil.SetStatusFromGitjob(ctx, r.Client, gitrepo, &job); err != nil {
-		return result(repoPolled, gitrepo), grutil.UpdateErrorStatus(ctx, r.Client, name, gitrepo.Status, err)
+	if err = setStatusFromGitjob(ctx, r.Client, gitrepo, &job); err != nil {
+		return result(repoPolled, gitrepo), updateErrorStatus(ctx, r.Client, name, gitrepo.Status, err)
 	}
 
 	return reconcile.Result{}, nil
@@ -387,7 +384,7 @@ func (r *GitJobReconciler) addGitRepoFinalizer(ctx context.Context, nsName types
 func (r *GitJobReconciler) createJobRBAC(ctx context.Context, gitrepo *v1alpha1.GitRepo) error {
 	// No update needed, values are the same. So we ignore AlreadyExists.
 	saName := name.SafeConcatName("git", gitrepo.Name)
-	sa := grutil.NewServiceAccount(gitrepo.Namespace, saName)
+	sa := newServiceAccount(gitrepo.Namespace, saName)
 	if err := controllerutil.SetControllerReference(gitrepo, sa, r.Scheme); err != nil {
 		return err
 	}
@@ -395,7 +392,7 @@ func (r *GitJobReconciler) createJobRBAC(ctx context.Context, gitrepo *v1alpha1.
 		return err
 	}
 
-	role := grutil.NewRole(gitrepo.Namespace, saName)
+	role := newRole(gitrepo.Namespace, saName)
 	if err := controllerutil.SetControllerReference(gitrepo, role, r.Scheme); err != nil {
 		return err
 	}
@@ -403,7 +400,7 @@ func (r *GitJobReconciler) createJobRBAC(ctx context.Context, gitrepo *v1alpha1.
 		return err
 	}
 
-	rb := grutil.NewRoleBinding(gitrepo.Namespace, saName)
+	rb := newRoleBinding(gitrepo.Namespace, saName)
 	if err := controllerutil.SetControllerReference(gitrepo, rb, r.Scheme); err != nil {
 		return err
 	}
@@ -415,7 +412,7 @@ func (r *GitJobReconciler) createJobRBAC(ctx context.Context, gitrepo *v1alpha1.
 }
 
 func (r *GitJobReconciler) createTargetsConfigMap(ctx context.Context, gitrepo *v1alpha1.GitRepo) error {
-	configMap, err := grutil.NewTargetsConfigMap(gitrepo)
+	configMap, err := newTargetsConfigMap(gitrepo)
 	if err != nil {
 		return err
 	}
@@ -663,7 +660,7 @@ func (r *GitJobReconciler) newJobSpec(ctx context.Context, gitrepo *v1alpha1.Git
 	}
 
 	// compute configmap, needed because its name contains a hash
-	configMap, err := grutil.NewTargetsConfigMap(gitrepo)
+	configMap, err := newTargetsConfigMap(gitrepo)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/cmd/controller/grutil/status.go b/internal/cmd/controller/gitops/reconciler/gitrepo_status.go
similarity index 89%
rename from internal/cmd/controller/grutil/status.go
rename to internal/cmd/controller/gitops/reconciler/gitrepo_status.go
index f2bfb847..e6dbdff0 100644
--- a/internal/cmd/controller/grutil/status.go
+++ b/internal/cmd/controller/gitops/reconciler/gitrepo_status.go
@@ -1,4 +1,4 @@
-package grutil
+package reconciler
 
 import (
 	"context"
@@ -73,7 +73,7 @@ func SetStatusFromBundleDeployments(ctx context.Context, c client.Client, gitrep
 		return err
 	}
 
-	gitrepo.Status.Summary = fleet.BundleSummary{}
+	var bundleSummary fleet.BundleSummary
 
 	sort.Slice(list.Items, func(i, j int) bool {
 		return list.Items[i].UID < list.Items[j].UID
@@ -87,8 +87,8 @@ func SetStatusFromBundleDeployments(ctx context.Context, c client.Client, gitrep
 	for _, bd := range list.Items {
 		bd := bd // fix gosec warning regarding "Implicit memory aliasing in for loop"
 		state := summary.GetDeploymentState(&bd)
-		summary.IncrementState(&gitrepo.Status.Summary, bd.Name, state, summary.MessageFromDeployment(&bd), bd.Status.ModifiedStatus, bd.Status.NonReadyStatus)
-		gitrepo.Status.Summary.DesiredReady++
+		summary.IncrementState(&bundleSummary, bd.Name, state, summary.MessageFromDeployment(&bd), bd.Status.ModifiedStatus, bd.Status.NonReadyStatus)
+		bundleSummary.DesiredReady++
 		if fleet.StateRank[state] > fleet.StateRank[maxState] {
 			maxState = state
 			message = summary.MessageFromDeployment(&bd)
@@ -99,16 +99,18 @@ func SetStatusFromBundleDeployments(ctx context.Context, c client.Client, gitrep
 		maxState = ""
 		message = ""
 	}
-
+	gitrepo.Status.Summary = bundleSummary
 	gitrepo.Status.Display.State = string(maxState)
 	gitrepo.Status.Display.Message = message
 	gitrepo.Status.Display.Error = len(message) > 0
 
+	setResources(list, gitrepo)
+
 	return nil
 }
 
-// SetStatusFromGitjob sets the status fields relative to the given job in the gitRepo
-func SetStatusFromGitjob(ctx context.Context, c client.Client, gitRepo *fleet.GitRepo, job *batchv1.Job) error {
+// setStatusFromGitjob sets the status fields relative to the given job in the gitRepo
+func setStatusFromGitjob(ctx context.Context, c client.Client, gitRepo *fleet.GitRepo, job *batchv1.Job) error {
 	obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(job)
 	if err != nil {
 		return err
@@ -194,20 +196,20 @@ func SetCondition(status *fleet.GitRepoStatus, err error) {
 	}
 }
 
-// UpdateErrorStatus sets the condition in the status and tries to update the resource
-func UpdateErrorStatus(ctx context.Context, c client.Client, req types.NamespacedName, status fleet.GitRepoStatus, orgErr error) error {
+// updateErrorStatus sets the condition in the status and tries to update the resource
+func updateErrorStatus(ctx context.Context, c client.Client, req types.NamespacedName, status fleet.GitRepoStatus, orgErr error) error {
 	SetCondition(&status, orgErr)
-	if statusErr := UpdateStatus(ctx, c, req, status); statusErr != nil {
+	if statusErr := updateStatus(ctx, c, req, status); statusErr != nil {
 		merr := []error{orgErr, fmt.Errorf("failed to update the status: %w", statusErr)}
 		return errutil.NewAggregate(merr)
 	}
 	return orgErr
 }
 
-// UpdateStatus updates the status for the GitRepo resource. It retries on
+// updateStatus updates the status for the GitRepo resource. It retries on
 // conflict. If the status was updated successfully, it also collects (as in
 // updates) metrics for the resource GitRepo resource.
-func UpdateStatus(ctx context.Context, c client.Client, req types.NamespacedName, status fleet.GitRepoStatus) error {
+func updateStatus(ctx context.Context, c client.Client, req types.NamespacedName, status fleet.GitRepoStatus) error {
 	return retry.RetryOnConflict(retry.DefaultRetry, func() error {
 		t := &fleet.GitRepo{}
 		err := c.Get(ctx, req, t)
diff --git a/internal/cmd/controller/grutil/resourcekey.go b/internal/cmd/controller/gitops/reconciler/resourcekey.go
similarity index 58%
rename from internal/cmd/controller/grutil/resourcekey.go
rename to internal/cmd/controller/gitops/reconciler/resourcekey.go
index 2163cd4d..16cfc52f 100644
--- a/internal/cmd/controller/grutil/resourcekey.go
+++ b/internal/cmd/controller/gitops/reconciler/resourcekey.go
@@ -1,55 +1,72 @@
-package grutil
+package reconciler
 
 import (
-	"context"
 	"encoding/json"
 	"sort"
 	"strings"
 
-	"github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
-	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
-func SetStatusFromResourceKey(ctx context.Context, c client.Client, gitrepo *fleet.GitRepo) {
-	state := bundleErrorState(gitrepo.Status.Summary)
-	gitrepo.Status.Resources, gitrepo.Status.ResourceErrors = fromResourceKey(ctx, c, gitrepo.Namespace, gitrepo.Name, state)
-	gitrepo.Status = countResources(gitrepo.Status)
+func setResources(list *fleet.BundleDeploymentList, gitrepo *fleet.GitRepo) {
+	s := summaryState(gitrepo.Status.Summary)
+	r, errors := fromResources(list, s)
+	gitrepo.Status.ResourceErrors = errors
+	gitrepo.Status.ResourceCounts = countResources(r)
+	gitrepo.Status.Resources = merge(r)
 }
 
-func bundleErrorState(summary fleet.BundleSummary) string {
-	bundleErrorState := ""
+// merge takes a list of GitRepo resources and deduplicates resources deployed to multiple clusters,
+// ensuring that for such resources, the output contains a single resource entry with a field summarizing
+// its status on each cluster.
+func merge(resources []fleet.GitRepoResource) []fleet.GitRepoResource {
+	merged := map[string]fleet.GitRepoResource{}
+	for _, resource := range resources {
+		key := key(resource)
+		if existing, ok := merged[key]; ok {
+			existing.PerClusterState = append(existing.PerClusterState, resource.PerClusterState...)
+			merged[key] = existing
+		} else {
+			merged[key] = resource
+		}
+	}
+
+	var result []fleet.GitRepoResource
+	for _, resource := range merged {
+		result = append(result, resource)
+	}
+
+	sort.Slice(result, func(i, j int) bool {
+		return key(result[i]) < key(result[j])
+	})
+	return result
+}
+
+func key(resource fleet.GitRepoResource) string {
+	return resource.Type + "/" + resource.ID
+}
+
+func summaryState(summary fleet.BundleSummary) string {
 	if summary.WaitApplied > 0 {
-		bundleErrorState = "WaitApplied"
+		return "WaitApplied"
 	}
 	if summary.ErrApplied > 0 {
-		bundleErrorState = "ErrApplied"
+		return "ErrApplied"
 	}
-	return bundleErrorState
+	return ""
 }
 
-// fromResourceKey lists all bundledeployments for this GitRepo and returns a list of
-// GitRepoResource states for all resources
+// fromResources inspects all bundledeployments for this GitRepo and returns a list of
+// GitRepoResources and error messages.
 //
 // It populates gitrepo status resources from bundleDeployments. BundleDeployment.Status.Resources is the list of deployed resources.
-func fromResourceKey(ctx context.Context, c client.Client, namespace, name string, bundleErrorState string) ([]fleet.GitRepoResource, []string) {
+func fromResources(list *fleet.BundleDeploymentList, summaryState string) ([]fleet.GitRepoResource, []string) {
 	var (
 		resources []fleet.GitRepoResource
 		errors    []string
 	)
 
-	bdList := &fleet.BundleDeploymentList{}
-	err := c.List(ctx, bdList, client.MatchingLabels{
-		fleet.RepoLabel:            name,
-		fleet.BundleNamespaceLabel: namespace,
-	})
-	if err != nil {
-		errors = append(errors, err.Error())
-		return resources, errors
-	}
-
-	for _, bd := range bdList.Items {
-		bd := bd // fix gosec warning regarding "Implicit memory aliasing in for loop"
+	for _, bd := range list.Items {
 		bdResources := bundleDeploymentResources(bd)
 		incomplete, err := addState(bd, bdResources)
 
@@ -60,32 +77,29 @@ func fromResourceKey(ctx context.Context, c client.Client, namespace, name strin
 			}
 		}
 
-		for k, state := range bdResources {
-			resource := toResourceState(k, state, incomplete, bundleErrorState)
+		for k, perCluster := range bdResources {
+			resource := toResourceState(k, perCluster, incomplete, summaryState)
 			resources = append(resources, resource)
 		}
 	}
 
 	sort.Strings(errors)
-	sort.Slice(resources, func(i, j int) bool {
-		return resources[i].Type+"/"+resources[i].ID < resources[j].Type+"/"+resources[j].ID
-	})
 
 	return resources, errors
 }
 
-func toResourceState(k fleet.ResourceKey, state []fleet.ResourcePerClusterState, incomplete bool, bundleErrorState string) fleet.GitRepoResource {
+func toResourceState(k fleet.ResourceKey, perCluster []fleet.ResourcePerClusterState, incomplete bool, summaryState string) fleet.GitRepoResource {
 	resource := fleet.GitRepoResource{
 		APIVersion:      k.APIVersion,
 		Kind:            k.Kind,
 		Namespace:       k.Namespace,
 		Name:            k.Name,
 		IncompleteState: incomplete,
-		PerClusterState: state,
+		PerClusterState: perCluster,
 	}
 	resource.Type, resource.ID = toType(resource)
 
-	for _, state := range state {
+	for _, state := range perCluster {
 		resource.State = state.State
 		resource.Error = state.Error
 		resource.Transitioning = state.Transitioning
@@ -93,22 +107,23 @@ func toResourceState(k fleet.ResourceKey, state []fleet.ResourcePerClusterState,
 		break
 	}
 
+	// fallback to state from gitrepo summary
 	if resource.State == "" {
 		if resource.IncompleteState {
-			if bundleErrorState != "" {
-				resource.State = bundleErrorState
+			if summaryState != "" {
+				resource.State = summaryState
 			} else {
 				resource.State = "Unknown"
 			}
-		} else if bundleErrorState != "" {
-			resource.State = bundleErrorState
+		} else if summaryState != "" {
+			resource.State = summaryState
 		} else {
 			resource.State = "Ready"
 		}
 	}
 
-	sort.Slice(state, func(i, j int) bool {
-		return state[i].ClusterID < state[j].ClusterID
+	sort.Slice(perCluster, func(i, j int) bool {
+		return perCluster[i].ClusterID < perCluster[j].ClusterID
 	})
 	return resource
 }
@@ -127,6 +142,9 @@ func toType(resource fleet.GitRepoResource) (string, string) {
 	return t, resource.Namespace + "/" + resource.Name
 }
 
+// addState adds per-cluster state information for nonReady and modified resources in a bundleDeployment.
+// It only adds up to 10 entries to not overwhelm the status.
+// It mutates resources and returns whether the reported state is incomplete and any errors encountered.
 func addState(bd fleet.BundleDeployment, resources map[fleet.ResourceKey][]fleet.ResourcePerClusterState) (bool, []error) {
 	var (
 		incomplete bool
@@ -137,7 +155,7 @@ func addState(bd fleet.BundleDeployment, resources map[fleet.ResourceKey][]fleet
 		incomplete = true
 	}
 
-	cluster := bd.Labels[v1alpha1.ClusterNamespaceLabel] + "/" + bd.Labels[v1alpha1.ClusterLabel]
+	cluster := bd.Labels[fleet.ClusterNamespaceLabel] + "/" + bd.Labels[fleet.ClusterLabel]
 	for _, nonReady := range bd.Status.NonReadyStatus {
 		key := fleet.ResourceKey{
 			Kind:       nonReady.Kind,
@@ -202,39 +220,39 @@ func appendState(states map[fleet.ResourceKey][]fleet.ResourcePerClusterState, k
 func bundleDeploymentResources(bd fleet.BundleDeployment) map[fleet.ResourceKey][]fleet.ResourcePerClusterState {
 	bdResources := map[fleet.ResourceKey][]fleet.ResourcePerClusterState{}
 	for _, resource := range bd.Status.Resources {
-		resourceKey := fleet.ResourceKey{
+		key := fleet.ResourceKey{
 			Kind:       resource.Kind,
 			APIVersion: resource.APIVersion,
 			Name:       resource.Name,
 			Namespace:  resource.Namespace,
 		}
-		bdResources[resourceKey] = []fleet.ResourcePerClusterState{}
+		bdResources[key] = []fleet.ResourcePerClusterState{}
 	}
 	return bdResources
 }
 
-func countResources(status fleet.GitRepoStatus) fleet.GitRepoStatus {
-	status.ResourceCounts = fleet.GitRepoResourceCounts{}
+func countResources(resources []fleet.GitRepoResource) fleet.GitRepoResourceCounts {
+	counts := fleet.GitRepoResourceCounts{}
 
-	for _, resource := range status.Resources {
-		status.ResourceCounts.DesiredReady++
+	for _, resource := range resources {
+		counts.DesiredReady++
 		switch resource.State {
 		case "Ready":
-			status.ResourceCounts.Ready++
+			counts.Ready++
 		case "WaitApplied":
-			status.ResourceCounts.WaitApplied++
+			counts.WaitApplied++
 		case "Modified":
-			status.ResourceCounts.Modified++
+			counts.Modified++
 		case "Orphan":
-			status.ResourceCounts.Orphaned++
+			counts.Orphaned++
 		case "Missing":
-			status.ResourceCounts.Missing++
+			counts.Missing++
 		case "Unknown":
-			status.ResourceCounts.Unknown++
+			counts.Unknown++
 		default:
-			status.ResourceCounts.NotReady++
+			counts.NotReady++
 		}
 	}
 
-	return status
+	return counts
 }
diff --git a/internal/cmd/controller/grutil/restrictions.go b/internal/cmd/controller/gitops/reconciler/restrictions.go
similarity index 96%
rename from internal/cmd/controller/grutil/restrictions.go
rename to internal/cmd/controller/gitops/reconciler/restrictions.go
index 51204e5c..28410bd6 100644
--- a/internal/cmd/controller/grutil/restrictions.go
+++ b/internal/cmd/controller/gitops/reconciler/restrictions.go
@@ -1,4 +1,4 @@
-package grutil
+package reconciler
 
 import (
 	"context"
@@ -11,8 +11,8 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
-// AuthorizeAndAssignDefaults applies restrictions and returns a new GitRepo if it passes the restrictions
-func AuthorizeAndAssignDefaults(ctx context.Context, c client.Client, gitrepo *fleet.GitRepo) (*fleet.GitRepo, error) {
+// authorizeAndAssignDefaults applies restrictions and returns a new GitRepo if it passes the restrictions
+func authorizeAndAssignDefaults(ctx context.Context, c client.Client, gitrepo *fleet.GitRepo) (*fleet.GitRepo, error) {
 	restrictions := &fleet.GitRepoRestrictionList{}
 	err := c.List(ctx, restrictions, client.InNamespace(gitrepo.Namespace))
 	if err != nil {
diff --git a/internal/cmd/controller/grutil/targetsyaml.go b/internal/cmd/controller/gitops/reconciler/targetsyaml.go
similarity index 91%
rename from internal/cmd/controller/grutil/targetsyaml.go
rename to internal/cmd/controller/gitops/reconciler/targetsyaml.go
index bd11739a..33991040 100644
--- a/internal/cmd/controller/grutil/targetsyaml.go
+++ b/internal/cmd/controller/gitops/reconciler/targetsyaml.go
@@ -1,4 +1,4 @@
-package grutil
+package reconciler
 
 import (
 	"encoding/json"
@@ -12,11 +12,11 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
-// NewTargetsConfigMap builds a config map, containing the GitTarget cluster matchers, converted to BundleTargets.
+// newTargetsConfigMap builds a config map, containing the GitTarget cluster matchers, converted to BundleTargets.
 // The BundleTargets are duplicated into TargetRestrictions. TargetRestrictions is a whilelist. A BundleDeployment
 // will be created for a Target just if it is inside a TargetRestrictions. If it is not inside TargetRestrictions a Target
 // is a TargetCustomization.
-func NewTargetsConfigMap(repo *fleet.GitRepo) (*corev1.ConfigMap, error) {
+func newTargetsConfigMap(repo *fleet.GitRepo) (*corev1.ConfigMap, error) {
 	spec := &fleet.BundleSpec{}
 	for _, target := range targetsOrDefault(repo.Spec.Targets) {
 		spec.Targets = append(spec.Targets, fleet.BundleTarget{


Test patch:
diff --git a/internal/cmd/controller/gitops/reconciler/resourcekey_test.go b/internal/cmd/controller/gitops/reconciler/resourcekey_test.go
new file mode 100644
index 00000000..295cd68c
--- /dev/null
+++ b/internal/cmd/controller/gitops/reconciler/resourcekey_test.go
@@ -0,0 +1,173 @@
+package reconciler
+
+import (
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+
+	"github.com/rancher/wrangler/v3/pkg/summary"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+)
+
+var _ = Describe("Resourcekey", func() {
+	var (
+		gitrepo *fleet.GitRepo
+		list    *fleet.BundleDeploymentList
+	)
+
+	BeforeEach(func() {
+		gitrepo = &fleet.GitRepo{
+			Status: fleet.GitRepoStatus{
+				Summary: fleet.BundleSummary{
+					Ready:       2,
+					WaitApplied: 1,
+				},
+			},
+		}
+		list = &fleet.BundleDeploymentList{
+			Items: []fleet.BundleDeployment{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "bd1",
+						Labels: map[string]string{
+							fleet.RepoLabel:             "gitrepo1",
+							fleet.ClusterLabel:          "cluster1",
+							fleet.ClusterNamespaceLabel: "c-ns1",
+						},
+					},
+					Status: fleet.BundleDeploymentStatus{
+						Resources: []fleet.BundleDeploymentResource{
+							{
+								Kind:       "Deployment",
+								APIVersion: "v1",
+								Name:       "web",
+								Namespace:  "default",
+							},
+							{
+								// extra service for one cluster
+								Kind:       "Service",
+								APIVersion: "v1",
+								Name:       "web-svc",
+								Namespace:  "default",
+							},
+						},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "bd1",
+						Labels: map[string]string{
+							fleet.RepoLabel:             "gitrepo1",
+							fleet.ClusterLabel:          "cluster2",
+							fleet.ClusterNamespaceLabel: "c-ns1",
+						},
+					},
+					Status: fleet.BundleDeploymentStatus{
+						Resources: []fleet.BundleDeploymentResource{
+							{
+								Kind:       "Deployment",
+								APIVersion: "v1",
+								Name:       "web",
+								Namespace:  "default",
+							},
+						},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "bd1",
+						Labels: map[string]string{
+							fleet.RepoLabel:             "gitrepo1",
+							fleet.ClusterLabel:          "cluster1",
+							fleet.ClusterNamespaceLabel: "c-ns2",
+						},
+					},
+					Status: fleet.BundleDeploymentStatus{
+						NonReadyStatus: []fleet.NonReadyStatus{
+							{
+								Kind:       "Deployment",
+								APIVersion: "v1",
+								Name:       "web",
+								Namespace:  "default",
+								Summary: summary.Summary{
+									State:         "Pending",
+									Error:         true,
+									Transitioning: true,
+									Message:       []string{"message1", "message2"},
+								},
+							},
+						},
+						Resources: []fleet.BundleDeploymentResource{
+							{
+								Kind:       "Deployment",
+								APIVersion: "v1",
+								Name:       "web",
+								Namespace:  "default",
+							},
+						},
+					},
+				},
+			},
+		}
+
+	})
+
+	It("returns a list", func() {
+		setResources(list, gitrepo)
+
+		Expect(gitrepo.Status.Resources).To(HaveLen(2))
+		Expect(gitrepo.Status.Resources).To(ContainElement(fleet.GitRepoResource{
+			APIVersion: "v1",
+			Kind:       "Deployment",
+			Type:       "deployment",
+			ID:         "default/web",
+
+			Namespace: "default",
+			Name:      "web",
+
+			IncompleteState: false,
+			State:           "WaitApplied",
+			Error:           false,
+			Transitioning:   false,
+			Message:         "",
+			PerClusterState: []fleet.ResourcePerClusterState{
+				{
+					State:         "Pending",
+					ClusterID:     "c-ns2/cluster1",
+					Error:         true,
+					Transitioning: true,
+					Message:       "message1; message2",
+					Patch:         nil,
+				},
+			},
+		}))
+		Expect(gitrepo.Status.Resources).To(ContainElement(fleet.GitRepoResource{
+			APIVersion: "v1",
+			Kind:       "Service",
+			Type:       "service",
+			ID:         "default/web-svc",
+
+			Namespace: "default",
+			Name:      "web-svc",
+
+			IncompleteState: false,
+			State:           "WaitApplied",
+			Error:           false,
+			Transitioning:   false,
+			Message:         "",
+			PerClusterState: []fleet.ResourcePerClusterState{},
+		}))
+
+		Expect(gitrepo.Status.ResourceErrors).To(BeEmpty())
+
+		Expect(gitrepo.Status.ResourceCounts.Ready).To(Equal(0))
+		Expect(gitrepo.Status.ResourceCounts.DesiredReady).To(Equal(4))
+		Expect(gitrepo.Status.ResourceCounts.WaitApplied).To(Equal(3))
+		Expect(gitrepo.Status.ResourceCounts.Modified).To(Equal(0))
+		Expect(gitrepo.Status.ResourceCounts.Orphaned).To(Equal(0))
+		Expect(gitrepo.Status.ResourceCounts.Missing).To(Equal(0))
+		Expect(gitrepo.Status.ResourceCounts.Unknown).To(Equal(0))
+		Expect(gitrepo.Status.ResourceCounts.NotReady).To(Equal(1))
+	})
+})
diff --git a/internal/cmd/controller/gitops/reconciler/suite_test.go b/internal/cmd/controller/gitops/reconciler/suite_test.go
new file mode 100644
index 00000000..82ce4c57
--- /dev/null
+++ b/internal/cmd/controller/gitops/reconciler/suite_test.go
@@ -0,0 +1,16 @@
+package reconciler
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func TestGitOpsReconciler(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "GitOps Reconciler Suite")
+}
+
+var _ = BeforeSuite(func() {
+})