Repository: rancher/fleet
Pull request #2336 (2024-04-17 14:37:25)

Switch to tini
catatonit is statically compiled and 10x larger


weyfonk: Do we even need to install tini? I've just found this in its README:
> NOTE: If you are using Docker 1.13 or greater, Tini is included in Docker itself. This includes all versions of Docker CE. To > enable Tini, just [pass the --init flag to docker run](https://docs.docker.com/engine/reference/commandline/run/).
manno: 
> Edit: not sure we'd have access to that flag when running images in k8s, plus compatibility, so scratch this.


Yes, different container runtime 🤷 

Patch:
diff --git a/.github/scripts/build-fleet-binaries.sh b/.github/scripts/build-fleet-binaries.sh
index a4158c74..4b914f23 100755
--- a/.github/scripts/build-fleet-binaries.sh
+++ b/.github/scripts/build-fleet-binaries.sh
@@ -13,7 +13,3 @@ go build -gcflags='all=-N -l' -o bin/fleetcontroller-linux-"$GOARCH" ./cmd/fleet
 # fleet agent
 go build -gcflags='all=-N -l' -o "bin/fleet-linux-$GOARCH" ./cmd/fleetcli
 go build -gcflags='all=-N -l' -o "bin/fleetagent-linux-$GOARCH" ./cmd/fleetagent
-
-# gitjob
-go build -gcflags='all=-N -l' -o "bin/gitcloner-linux-$GOARCH" ./cmd/gitcloner
-go build -gcflags='all=-N -l' -o "bin/gitjob-linux-$GOARCH" ./cmd/gitjob
diff --git a/.github/scripts/deploy-fleet.sh b/.github/scripts/deploy-fleet.sh
index 375dd2f4..22cb6405 100755
--- a/.github/scripts/deploy-fleet.sh
+++ b/.github/scripts/deploy-fleet.sh
@@ -2,6 +2,8 @@
 
 set -euxo pipefail
 
+shards=${SHARDS-""}
+
 function eventually {
   for _ in $(seq 1 3); do
     "$@" && return 0
@@ -35,6 +37,8 @@ eventually helm upgrade --install fleet charts/fleet \
   --set agentImage.repository="$agentRepo" \
   --set agentImage.tag="$agentTag" \
   --set agentImage.imagePullPolicy=IfNotPresent \
+  --set shards="{$shards}" \
+  --set debug=true --set debugLevel=1
 
 # wait for controller and agent rollout
 kubectl -n cattle-fleet-system rollout status deploy/fleet-controller
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index aacfc120..437ef004 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -27,7 +27,7 @@ jobs:
           export PATH=$PATH:/home/runner/go/bin/
 
       - name: golangci-lint
-        uses: golangci/golangci-lint-action@v4.0.0
+        uses: golangci/golangci-lint-action@v5.0.0
         with:
           # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version.
           version: v1.57
diff --git a/.github/workflows/release-fleet.yml b/.github/workflows/release-fleet.yml
index 786ad895..1a3573a1 100644
--- a/.github/workflows/release-fleet.yml
+++ b/.github/workflows/release-fleet.yml
@@ -33,7 +33,7 @@ jobs:
           check-latest: true
 
       - name: Run golangci-lint
-        uses: golangci/golangci-lint-action@v4.0.0
+        uses: golangci/golangci-lint-action@v5.0.0
         with:
           # Required: the version of golangci-lint is required and must be specified without patch version: we always
           # use the latest patch version.
diff --git a/.gitignore b/.gitignore
index 33d19e41..9edb4e9c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,5 @@ e2e/testenv/infra/infra
 ^fleet$
 FleetCI-RootCA
 .envrc
+env.multi-cluster
+env.single-cluster
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 836632dc..e8109875 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -20,18 +20,6 @@ archives:
     builds:
       - fleet-agent
 
-  - id: fleet-gitcloner
-    format: binary
-    name_template: "{{ .Binary }}"
-    builds:
-      - fleet-gitcloner
-
-  - id: fleet-gitjob
-    format: binary
-    name_template: "{{ .Binary }}"
-    builds:
-      - fleet-gitjob
-
   - id: fleet-cli
     format: binary
     name_template: "{{ .Binary }}"
@@ -66,30 +54,6 @@ builds:
     - linux_amd64_v1
     - linux_arm64
     - windows_amd64
-  -
-    id: fleet-gitcloner
-    main: ./cmd/gitcloner
-    binary: gitcloner-{{ .Os }}-{{ .Arch }}{{ if .Arm }}64{{ end }}
-    no_unique_dist_dir: true
-    ldflags:
-      - -w -s
-      - -X github.com/rancher/fleet/pkg/version.GitCommit={{ .Commit }}
-      - -X github.com/rancher/fleet/pkg/version.Version={{ .Tag }}
-    targets:
-    - linux_amd64_v1
-    - linux_arm64
-  -
-    id: fleet-gitjob
-    main: ./cmd/gitjob
-    binary: gitjob-{{ .Os }}-{{ .Arch }}{{ if .Arm }}64{{ end }}
-    no_unique_dist_dir: true
-    ldflags:
-      - -w -s
-      - -X github.com/rancher/fleet/pkg/version.GitCommit={{ .Commit }}
-      - -X github.com/rancher/fleet/pkg/version.Version={{ .Tag }}
-    targets:
-    - linux_amd64_v1
-    - linux_arm64
   -
     id: fleet-cli
     main: ./cmd/fleetcli
@@ -130,8 +94,6 @@ dockers:
     ids:
     - fleet-controller
     - fleet-cli
-    - fleet-gitcloner
-    - fleet-gitjob
 
     # Templates of the Docker image names.
     image_templates:
@@ -159,8 +121,6 @@ dockers:
     ids:
     - fleet-controller
     - fleet-cli
-    - fleet-gitcloner
-    - fleet-gitjob
     image_templates:
     - "docker.io/rancher/fleet:{{ .Tag }}-linux-arm64"
     dockerfile: package/Dockerfile
diff --git a/README.md b/README.md
index 736865dd..5b21dfd3 100644
--- a/README.md
+++ b/README.md
@@ -37,9 +37,9 @@ Install the Fleet Helm charts (there's two because we separate out CRDs for ulti
 
 ```shell
 helm -n cattle-fleet-system install --create-namespace --wait \
-    fleet-crd https://github.com/rancher/fleet/releases/download/v0.9.2/fleet-crd-0.9.2.tgz
+    fleet-crd https://github.com/rancher/fleet/releases/download/v0.9.3/fleet-crd-0.9.3.tgz
 helm -n cattle-fleet-system install --create-namespace --wait \
-    fleet https://github.com/rancher/fleet/releases/download/v0.9.2/fleet-0.9.2.tgz
+    fleet https://github.com/rancher/fleet/releases/download/v0.9.3/fleet-0.9.3.tgz
 ```
 
 ## Add a Git Repo to watch
diff --git a/charts/fleet-agent/templates/deployment.yaml b/charts/fleet-agent/templates/deployment.yaml
index d9348424..2a3409ed 100644
--- a/charts/fleet-agent/templates/deployment.yaml
+++ b/charts/fleet-agent/templates/deployment.yaml
@@ -3,6 +3,7 @@ kind: StatefulSet
 metadata:
   name: fleet-agent
 spec:
+  serviceName: fleet-agent
   selector:
     matchLabels:
       app: fleet-agent
diff --git a/charts/fleet-agent/templates/service.yaml b/charts/fleet-agent/templates/service.yaml
new file mode 100644
index 00000000..36d35588
--- /dev/null
+++ b/charts/fleet-agent/templates/service.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: fleet-agent
+spec:
+    type: ClusterIP
+    clusterIP: None
+    selector:
+      app: fleet-agent
diff --git a/charts/fleet/templates/deployment.yaml b/charts/fleet/templates/deployment.yaml
index b31a1d6d..d2e95fb8 100644
--- a/charts/fleet/templates/deployment.yaml
+++ b/charts/fleet/templates/deployment.yaml
@@ -1,7 +1,12 @@
+{{ $shards := list "" }}
+{{ if .Values.shards }}
+{{ $shards = concat $shards .Values.shards | uniq }}
+{{ end }}
+{{ range $shards }}
 apiVersion: apps/v1
 kind: Deployment
 metadata:
-  name: fleet-controller
+  name: "fleet-controller{{if . }}-shard-{{ . }}{{end}}"
 spec:
   selector:
     matchLabels:
@@ -10,6 +15,10 @@ spec:
     metadata:
       labels:
         app: fleet-controller
+        shard: "{{ . }}"
+        {{- if empty . }}
+        shard-default: "true"
+        {{- end }}
     spec:
       containers:
       - env:
@@ -18,55 +27,67 @@ spec:
             fieldRef:
               fieldPath: metadata.namespace
         - name: FLEET_PROPAGATE_DEBUG_SETTINGS_TO_AGENTS
-          value: {{ quote .Values.propagateDebugSettingsToAgents }}
-        {{- if .Values.clusterEnqueueDelay }}
+          value: {{ quote $.Values.propagateDebugSettingsToAgents }}
+        {{- if $.Values.clusterEnqueueDelay }}
         - name: FLEET_CLUSTER_ENQUEUE_DELAY
-          value: {{ .Values.clusterEnqueueDelay }}
+          value: {{ $.Values.clusterEnqueueDelay }}
         {{- end }}
-        {{- if .Values.proxy }}
+        {{- if $.Values.proxy }}
         - name: HTTP_PROXY
-          value: {{ .Values.proxy }}
+          value: {{ $.Values.proxy }}
         - name: HTTPS_PROXY
-          value: {{ .Values.proxy }}
+          value: {{ $.Values.proxy }}
         - name: NO_PROXY
-          value: {{ .Values.noProxy }}
+          value: {{ $.Values.noProxy }}
         {{- end }}
-        {{- if .Values.cpuPprof }}
+        {{- if $.Values.cpuPprof }}
         - name: FLEET_CPU_PPROF_DIR
           value: /tmp/pprof/
         {{- end }}
-        {{- if .Values.cpuPprof }}
+        {{- if $.Values.cpuPprof }}
         - name: FLEET_CPU_PPROF_PERIOD
-          value: {{ quote .Values.cpuPprof.period }}
+          value: {{ quote $.Values.cpuPprof.period }}
         {{- end }}
-        {{- if .Values.leaderElection.leaseDuration }}
+        {{- if $.Values.leaderElection.leaseDuration }}
         - name: CATTLE_ELECTION_LEASE_DURATION
-          value: {{.Values.leaderElection.leaseDuration}}
+          value: {{$.Values.leaderElection.leaseDuration}}
         {{- end }}
-        {{- if .Values.leaderElection.retryPeriod }}
+        {{- if $.Values.leaderElection.retryPeriod }}
         - name: CATTLE_ELECTION_RETRY_PERIOD
-          value: {{.Values.leaderElection.retryPeriod}}
+          value: {{$.Values.leaderElection.retryPeriod}}
         {{- end }}
-        {{- if .Values.leaderElection.renewDeadline }}
+        {{- if $.Values.leaderElection.renewDeadline }}
         - name: CATTLE_ELECTION_RENEW_DEADLINE
-          value: {{.Values.leaderElection.renewDeadline}}
+          value: {{$.Values.leaderElection.renewDeadline}}
         {{- end }}
-        {{- if .Values.debug }}
+        {{- if $.Values.debug }}
         - name: CATTLE_DEV_MODE
           value: "true"
         {{- end }}
-        image: '{{ template "system_default_registry" . }}{{ .Values.image.repository }}:{{ .Values.image.tag }}'
+        image: '{{ template "system_default_registry" $ }}{{ $.Values.image.repository }}:{{ $.Values.image.tag }}'
         name: fleet-controller
-        imagePullPolicy: "{{ .Values.image.imagePullPolicy }}"
+        imagePullPolicy: "{{ $.Values.image.imagePullPolicy }}"
+        {{- if $.Values.metrics.enabled }}
+        ports:
+        - containerPort: 8080
+          name: metrics
+        {{- end }}
         command:
         - fleetcontroller
-        {{- if not .Values.gitops.enabled }}
+        {{- if not $.Values.gitops.enabled }}
         - --disable-gitops
         {{- end }}
-        {{- if .Values.debug }}
+        {{- if . }}
+        - --shard-id
+        - {{ quote . }}
+        {{- end }}
+        {{- if not $.Values.metrics.enabled }}
+        - --disable-metrics
+        {{- end }}
+        {{- if $.Values.debug }}
         - --debug
         - --debug-level
-        - {{ quote .Values.debugLevel }}
+        - {{ quote $.Values.debugLevel }}
         {{- else }}
         securityContext:
           allowPrivilegeEscalation: false
@@ -79,41 +100,42 @@ spec:
         volumeMounts:
           - mountPath: /tmp
             name: tmp
-        {{- if .Values.cpuPprof }}
+        {{- if $.Values.cpuPprof }}
           - mountPath: /tmp/pprof
             name: pprof
         {{- end }}
+      {{- if not . }} # Only deploy cleanup and agent management through sharding-less deployment
       - env:
         - name: NAMESPACE
           valueFrom:
             fieldRef:
               fieldPath: metadata.namespace
-        {{- if .Values.debug }}
+        {{- if $.Values.debug }}
         - name: CATTLE_DEV_MODE
           value: "true"
         {{- end }}
-        {{- if .Values.leaderElection.leaseDuration }}
+        {{- if $.Values.leaderElection.leaseDuration }}
         - name: CATTLE_ELECTION_LEASE_DURATION
-          value: {{.Values.leaderElection.leaseDuration}}
+          value: {{$.Values.leaderElection.leaseDuration}}
         {{- end }}
-        {{- if .Values.leaderElection.retryPeriod }}
+        {{- if $.Values.leaderElection.retryPeriod }}
         - name: CATTLE_ELECTION_RETRY_PERIOD
-          value: {{.Values.leaderElection.retryPeriod}}
+          value: {{$.Values.leaderElection.retryPeriod}}
         {{- end }}
-        {{- if .Values.leaderElection.renewDeadline }}
+        {{- if $.Values.leaderElection.renewDeadline }}
         - name: CATTLE_ELECTION_RENEW_DEADLINE
-          value: {{.Values.leaderElection.renewDeadline}}
+          value: {{$.Values.leaderElection.renewDeadline}}
         {{- end }}
-        image: '{{ template "system_default_registry" . }}{{ .Values.image.repository }}:{{ .Values.image.tag }}'
+        image: '{{ template "system_default_registry" $ }}{{ $.Values.image.repository }}:{{ $.Values.image.tag }}'
         name: fleet-cleanup
-        imagePullPolicy: "{{ .Values.image.imagePullPolicy }}"
+        imagePullPolicy: "{{ $.Values.image.imagePullPolicy }}"
         command:
         - fleetcontroller
         - cleanup
-        {{- if .Values.debug }}
+        {{- if $.Values.debug }}
         - --debug
         - --debug-level
-        - {{ quote .Values.debugLevel }}
+        - {{ quote $.Values.debugLevel }}
         {{- else }}
         securityContext:
           allowPrivilegeEscalation: false
@@ -128,35 +150,35 @@ spec:
           valueFrom:
             fieldRef:
               fieldPath: metadata.namespace
-        {{- if .Values.debug }}
+        {{- if $.Values.debug }}
         - name: CATTLE_DEV_MODE
           value: "true"
         {{- end }}
-        {{- if .Values.leaderElection.leaseDuration }}
+        {{- if $.Values.leaderElection.leaseDuration }}
         - name: CATTLE_ELECTION_LEASE_DURATION
-          value: {{.Values.leaderElection.leaseDuration}}
+          value: {{$.Values.leaderElection.leaseDuration}}
         {{- end }}
-        {{- if .Values.leaderElection.retryPeriod }}
+        {{- if $.Values.leaderElection.retryPeriod }}
         - name: CATTLE_ELECTION_RETRY_PERIOD
-          value: {{.Values.leaderElection.retryPeriod}}
+          value: {{$.Values.leaderElection.retryPeriod}}
         {{- end }}
-        {{- if .Values.leaderElection.renewDeadline }}
+        {{- if $.Values.leaderElection.renewDeadline }}
         - name: CATTLE_ELECTION_RENEW_DEADLINE
-          value: {{.Values.leaderElection.renewDeadline}}
+          value: {{$.Values.leaderElection.renewDeadline}}
         {{- end }}
-        image: '{{ template "system_default_registry" . }}{{ .Values.image.repository }}:{{ .Values.image.tag }}'
+        image: '{{ template "system_default_registry" $ }}{{ $.Values.image.repository }}:{{ $.Values.image.tag }}'
         name: fleet-agentmanagement
-        imagePullPolicy: "{{ .Values.image.imagePullPolicy }}"
+        imagePullPolicy: "{{ $.Values.image.imagePullPolicy }}"
         command:
         - fleetcontroller
         - agentmanagement
-        {{- if not .Values.bootstrap.enabled }}
+        {{- if not $.Values.bootstrap.enabled }}
         - --disable-bootstrap
         {{- end }}
-        {{- if .Values.debug }}
+        {{- if $.Values.debug }}
         - --debug
         - --debug-level
-        - {{ quote .Values.debugLevel }}
+        - {{ quote $.Values.debugLevel }}
         {{- else }}
         securityContext:
           allowPrivilegeEscalation: false
@@ -166,29 +188,32 @@ spec:
             drop:
             - ALL
         {{- end }}
+      {{- end }}
       volumes:
         - name: tmp
           emptyDir: {}
-      {{- if .Values.cpuPprof }}
-        - name: pprof {{ toYaml .Values.cpuPprof.volumeConfiguration | nindent 10 }}
+      {{- if $.Values.cpuPprof }}
+        - name: pprof {{ toYaml $.Values.cpuPprof.volumeConfiguration | nindent 10 }}
       {{- end }}
 
       serviceAccountName: fleet-controller
       nodeSelector: {{ include "linux-node-selector" . | nindent 8 }}
-{{- if .Values.nodeSelector }}
-{{ toYaml .Values.nodeSelector | indent 8 }}
+{{- if $.Values.nodeSelector }}
+{{ toYaml $.Values.nodeSelector | indent 8 }}
 {{- end }}
       tolerations: {{ include "linux-node-tolerations" . | nindent 8 }}
-{{- if .Values.tolerations }}
-{{ toYaml .Values.tolerations | indent 8 }}
+{{- if $.Values.tolerations }}
+{{ toYaml $.Values.tolerations | indent 8 }}
 {{- end }}
-      {{- if .Values.priorityClassName }}
-      priorityClassName: "{{.Values.priorityClassName}}"
+      {{- if $.Values.priorityClassName }}
+      priorityClassName: "{{$.Values.priorityClassName}}"
       {{- end }}
 
-{{- if not .Values.debug }}
+{{- if not $.Values.debug }}
       securityContext:
         runAsNonRoot: true
         runAsUser: 1000
         runAsGroup: 1000
 {{- end }}
+---
+{{- end }}
diff --git a/charts/fleet/templates/deployment_gitjob.yaml b/charts/fleet/templates/deployment_gitjob.yaml
index 699b9997..135a213e 100644
--- a/charts/fleet/templates/deployment_gitjob.yaml
+++ b/charts/fleet/templates/deployment_gitjob.yaml
@@ -17,6 +17,7 @@ spec:
         - image: "{{ template "system_default_registry" . }}{{ .Values.image.repository }}:{{ .Values.image.tag }}"
           name: gitjob
           args:
+          - fleetcontroller
           - gitjob
           - --gitjob-image
           - "{{ template "system_default_registry" . }}{{ .Values.image.repository }}:{{ .Values.image.tag }}"
diff --git a/charts/fleet/templates/service.yaml b/charts/fleet/templates/service.yaml
new file mode 100644
index 00000000..cc17e9b2
--- /dev/null
+++ b/charts/fleet/templates/service.yaml
@@ -0,0 +1,17 @@
+{{- if .Values.metrics.enabled }}
+apiVersion: v1
+kind: Service
+metadata:
+  name: monitoring-fleet-controller
+  labels:
+    app: fleet-controller
+spec:
+  type: ClusterIP
+  ports:
+  - port: 8080
+    targetPort: 8080
+    protocol: TCP
+    name: metrics
+  selector:
+    app: fleet-controller
+{{- end }}
diff --git a/charts/fleet/values.yaml b/charts/fleet/values.yaml
index e3604079..5582fb9f 100644
--- a/charts/fleet/values.yaml
+++ b/charts/fleet/values.yaml
@@ -66,6 +66,9 @@ priorityClassName: ""
 gitops:
   enabled: true
 
+metrics:
+  enabled: true
+
 debug: false
 debugLevel: 0
 propagateDebugSettingsToAgents: true
diff --git a/cmd/gitcloner/main.go b/cmd/gitcloner/main.go
deleted file mode 100644
index 09d48e29..00000000
--- a/cmd/gitcloner/main.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package main
-
-import (
-	"os"
-
-	"github.com/rancher/fleet/cmd/gitcloner/cmd"
-	"github.com/rancher/fleet/cmd/gitcloner/gogit"
-	"github.com/sirupsen/logrus"
-)
-
-func main() {
-	logrus.Info("Starting to clone git repository")
-	cmd := cmd.New(gogit.NewCloner())
-	if err := cmd.Execute(); err != nil {
-		logrus.Errorf("Error cloning repository: %v", err)
-		os.Exit(1)
-	}
-}
diff --git a/dev/build-fleet b/dev/build-fleet
index c8ea1cb5..c5eaa3f2 100755
--- a/dev/build-fleet
+++ b/dev/build-fleet
@@ -17,9 +17,6 @@ if ! git diff --quiet HEAD origin/main --  pkg/apis/fleet.cattle.io/v1alpha1; th
 fi
 
 export GOOS=linux
-# gitjob
-go build -gcflags='all=-N -l' -o "bin/gitcloner-linux-$GOARCH" ./cmd/gitcloner
-go build -gcflags='all=-N -l' -o "bin/gitjob-linux-$GOARCH" ./cmd/gitjob
 
 # fleet
 go build -gcflags='all=-N -l' -o "bin/fleet-linux-$GOARCH" ./cmd/fleetcli
diff --git a/dev/setup-fleet b/dev/setup-fleet
index 89b28f0b..81625e8d 100755
--- a/dev/setup-fleet
+++ b/dev/setup-fleet
@@ -4,6 +4,7 @@
 set -euxo pipefail
 
 cluster_name=${1-upstream}
+shards=${2-""}
 
 if [ ! -d ./charts/fleet ]; then
   echo "please change the current directory to the fleet repo checkout"
@@ -18,6 +19,7 @@ helm -n cattle-fleet-system upgrade --install --create-namespace --wait fleet-cr
 helm -n cattle-fleet-system upgrade --install --create-namespace --wait --reset-values \
   --set apiServerCA="$ca" \
   --set apiServerURL="$server" \
+  --set shards="{$shards}" \
   --set debug=true --set debugLevel=1 fleet charts/fleet
 
 # wait for controller and agent rollout
diff --git a/dev/setup-single-cluster b/dev/setup-single-cluster
index 9fedeb60..1af5fea1 100755
--- a/dev/setup-single-cluster
+++ b/dev/setup-single-cluster
@@ -12,7 +12,7 @@ source ./dev/setup-cluster-config
 ./dev/setup-k3d
 ./dev/build-fleet
 ./dev/import-images-k3d
-./dev/setup-fleet
+./dev/setup-fleet upstream shard1,shard2,shard3
 
 # needed for gitrepo tests
 ./dev/import-images-tests-k3d
diff --git a/go.mod b/go.mod
index 02357db0..8c374afd 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.22
 replace (
 	github.com/imdario/mergo => github.com/imdario/mergo v0.3.16
 	github.com/rancher/fleet/pkg/apis => ./pkg/apis
-	helm.sh/helm/v3 => github.com/rancher/helm/v3 v3.14.0-fleet2
+	helm.sh/helm/v3 => github.com/rancher/helm/v3 v3.14.4-fleet.0
 )
 
 require (
@@ -13,7 +13,7 @@ require (
 	github.com/Masterminds/sprig/v3 v3.2.3
 	github.com/cheggaaa/pb v1.0.29
 	github.com/davecgh/go-spew v1.1.1
-	github.com/docker/docker v26.0.1+incompatible
+	github.com/docker/docker v26.1.0+incompatible
 	github.com/evanphx/json-patch v5.9.0+incompatible
 	github.com/go-git/go-billy/v5 v5.5.0
 	github.com/go-git/go-git/v5 v5.12.0
@@ -28,11 +28,14 @@ require (
 	github.com/hashicorp/go-getter v1.7.4
 	github.com/jpillora/backoff v1.0.0
 	github.com/onsi/ginkgo/v2 v2.17.1
-	github.com/onsi/gomega v1.32.0
+	github.com/onsi/gomega v1.33.0
 	github.com/otiai10/copy v1.14.0
 	github.com/pkg/errors v0.9.1
+	github.com/prometheus/client_golang v1.19.0
+	github.com/prometheus/client_model v0.6.1
+	github.com/prometheus/common v0.53.0
 	github.com/rancher/fleet/pkg/apis v0.10.0-rc.4
-	github.com/rancher/lasso v0.0.0-20240325194215-0064abcb8aee
+	github.com/rancher/lasso v0.0.0-20240424194130-d87ec407d941
 	github.com/rancher/wrangler/v2 v2.1.4
 	github.com/reugn/go-quartz v0.11.2
 	github.com/sirupsen/logrus v1.9.3
@@ -46,13 +49,13 @@ require (
 	gopkg.in/yaml.v2 v2.4.0
 	gotest.tools v2.2.0+incompatible
 	helm.sh/helm/v3 v3.14.4
-	k8s.io/api v0.29.2
+	k8s.io/api v0.29.3
 	k8s.io/apiextensions-apiserver v0.29.2
-	k8s.io/apimachinery v0.29.2
+	k8s.io/apimachinery v0.29.3
 	k8s.io/cli-runtime v0.29.2
-	k8s.io/client-go v0.29.2
+	k8s.io/client-go v0.29.3
 	k8s.io/klog/v2 v2.120.1
-	k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3
+	k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108
 	k8s.io/kubectl v0.29.2
 	k8s.io/kubernetes v1.29.2
 	sigs.k8s.io/cli-utils v0.34.0
@@ -122,7 +125,7 @@ require (
 	github.com/gobuffalo/flect v1.0.2 // indirect
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/golang/protobuf v1.5.3 // indirect
+	github.com/golang/protobuf v1.5.4 // indirect
 	github.com/gomodule/redigo v2.0.0+incompatible // indirect
 	github.com/google/btree v1.1.2 // indirect
 	github.com/google/gnostic-models v0.6.8 // indirect
@@ -185,9 +188,6 @@ require (
 	github.com/pjbgf/sha1cd v0.3.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
-	github.com/prometheus/client_golang v1.19.0 // indirect
-	github.com/prometheus/client_model v0.6.0 // indirect
-	github.com/prometheus/common v0.48.0 // indirect
 	github.com/prometheus/procfs v0.12.0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/rogpeppe/go-internal v1.12.0 // indirect
@@ -220,8 +220,8 @@ require (
 	go.uber.org/zap v1.26.0 // indirect
 	golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
 	golang.org/x/mod v0.16.0 // indirect
-	golang.org/x/net v0.22.0 // indirect
-	golang.org/x/oauth2 v0.17.0 // indirect
+	golang.org/x/net v0.23.0 // indirect
+	golang.org/x/oauth2 v0.18.0 // indirect
 	golang.org/x/sys v0.19.0 // indirect
 	golang.org/x/term v0.19.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
diff --git a/go.sum b/go.sum
index 0c406fc6..70ccc345 100644
--- a/go.sum
+++ b/go.sum
@@ -196,8 +196,8 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
-github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@@ -305,8 +305,8 @@ github.com/docker/cli v25.0.1+incompatible h1:mFpqnrS6Hsm3v1k7Wa/BO23oz0k121MTbT
 github.com/docker/cli v25.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v26.0.1+incompatible h1:t39Hm6lpXuXtgkF0dm1t9a5HkbUfdGy6XbWexmGr+hA=
-github.com/docker/docker v26.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v26.1.0+incompatible h1:W1G9MPNbskA6VZWL7b3ZljTh0pXI68FpINx0GKaOdaM=
+github.com/docker/docker v26.1.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A=
 github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0=
 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
@@ -447,8 +447,8 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
-github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
 github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
@@ -700,8 +700,8 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
 github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
 github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8=
 github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs=
-github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk=
-github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg=
+github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE=
+github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -733,21 +733,21 @@ github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdU
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
-github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
 github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
 github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
-github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
-github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
+github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE=
+github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
 github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
 github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
 github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
-github.com/rancher/helm/v3 v3.14.0-fleet2 h1:DpduV+vYN1u8VnoH0wvVfgLm1BEsjvg6/FIBOh8bRvw=
-github.com/rancher/helm/v3 v3.14.0-fleet2/go.mod h1:2itvvDv2WSZXTllknfQo6j7u3VVgMAvm8POCDgYH424=
-github.com/rancher/lasso v0.0.0-20240325194215-0064abcb8aee h1:3BgZZIJQABqFxfgjAPchBm9afHQ1Vj7QDywZnKyWlQw=
-github.com/rancher/lasso v0.0.0-20240325194215-0064abcb8aee/go.mod h1:AroEHDeG/fAXtUyrk4W03kyizvh5hNHFf2n+DtwZP7s=
+github.com/rancher/helm/v3 v3.14.4-fleet.0 h1:fMyGOkEc6laHsr/kZn4GaZ37//pGBNioP9Kkf5+phB8=
+github.com/rancher/helm/v3 v3.14.4-fleet.0/go.mod h1:Tje7LL4gprZpuBNTbG34d1Xn5NmRT3OWfBRwpOSer9I=
+github.com/rancher/lasso v0.0.0-20240424194130-d87ec407d941 h1:1SvuoeyfANRvKVJUSzHWa1P781iuH8ktUjW9cPOxAAk=
+github.com/rancher/lasso v0.0.0-20240424194130-d87ec407d941/go.mod h1:pYKOe2r/5O0w3ypoc7xHQF8LvWCp5PsNRea1Jpq3vBU=
 github.com/rancher/wrangler/v2 v2.1.4 h1:ohov0i6A9dJHHO6sjfsH4Dqv93ZTdm5lIJVJdPzVdQc=
 github.com/rancher/wrangler/v2 v2.1.4/go.mod h1:af5OaGU/COgreQh1mRbKiUI64draT2NN34uk+PALFY8=
 github.com/reugn/go-quartz v0.11.2 h1:+jc54Ji06n/D/endEPmc+CuG/Jc8466nda1oxtFRrks=
@@ -985,8 +985,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
-golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
+golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1012,8 +1012,8 @@ golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri
 golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
 golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
 golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A=
-golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
-golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
+golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
+golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1469,18 +1469,18 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
-k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A=
-k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0=
+k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw=
+k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80=
 k8s.io/apiextensions-apiserver v0.29.2 h1:UK3xB5lOWSnhaCk0RFZ0LUacPZz9RY4wi/yt2Iu+btg=
 k8s.io/apiextensions-apiserver v0.29.2/go.mod h1:aLfYjpA5p3OwtqNXQFkhJ56TB+spV8Gc4wfMhUA3/b8=
-k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8=
-k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU=
+k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU=
+k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU=
 k8s.io/apiserver v0.29.2 h1:+Z9S0dSNr+CjnVXQePG8TcBWHr3Q7BmAr7NraHvsMiQ=
 k8s.io/apiserver v0.29.2/go.mod h1:B0LieKVoyU7ykQvPFm7XSdIHaCHSzCzQWPFa5bqbeMQ=
 k8s.io/cli-runtime v0.29.2 h1:smfsOcT4QujeghsNjECKN3lwyX9AwcFU0nvJ7sFN3ro=
 k8s.io/cli-runtime v0.29.2/go.mod h1:KLisYYfoqeNfO+MkTWvpqIyb1wpJmmFJhioA0xd4MW8=
-k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg=
-k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA=
+k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg=
+k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0=
 k8s.io/code-generator v0.29.2 h1:c9/iw2KnNpw2IRV+wwuG/Wns2TjPSgjWzbbjTevyiHI=
 k8s.io/code-generator v0.29.2/go.mod h1:FwFi3C9jCrmbPjekhaCYcYG1n07CYiW1+PAPCockaos=
 k8s.io/component-base v0.29.2 h1:lpiLyuvPA9yV1aQwGLENYyK7n/8t6l3nn3zAtFTJYe8=
@@ -1490,8 +1490,8 @@ k8s.io/gengo v0.0.0-20230829151522-9cce18d56c01/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAE
 k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
 k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw=
 k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
-k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3 h1:SbdLaI6mM6ffDSJCadEaD4IkuPzepLDGlkd2xV0t1uA=
-k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
+k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108 h1:Q8Z7VlGhcJgBHJHYugJ/K/7iB8a2eSxCyxdVjJp+lLY=
+k8s.io/kube-openapi v0.0.0-20240423202451-8948a665c108/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
 k8s.io/kubectl v0.29.2 h1:uaDYaBhumvkwz0S2XHt36fK0v5IdNgL7HyUniwb2IUo=
 k8s.io/kubectl v0.29.2/go.mod h1:BhizuYBGcKaHWyq+G7txGw2fXg576QbPrrnQdQDZgqI=
 k8s.io/kubernetes v1.29.2 h1:8hh1cntqdulanjQt7wSSSsJfBgOyx6fUdFWslvGL5m0=
diff --git a/cmd/gitcloner/gogit/cloner.go b/internal/cmd/cli/gitcloner/cloner.go
similarity index 91%
rename from cmd/gitcloner/gogit/cloner.go
rename to internal/cmd/cli/gitcloner/cloner.go
index ffe471eb..511c5e32 100644
--- a/cmd/gitcloner/gogit/cloner.go
+++ b/internal/cmd/cli/gitcloner/cloner.go
@@ -1,11 +1,9 @@
-package gogit
+package gitcloner
 
 import (
 	"fmt"
 	"os"
 
-	"github.com/rancher/fleet/cmd/gitcloner/cmd"
-
 	"github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
@@ -35,11 +33,11 @@ type Options struct {
 	CABundle        []byte
 }
 
-func NewCloner() *Cloner {
+func New() *Cloner {
 	return &Cloner{}
 }
 
-func (c *Cloner) CloneRepo(opts *cmd.Options) error {
+func (c *Cloner) CloneRepo(opts *GitCloner) error {
 	// Azure DevOps requires capabilities multi_ack / multi_ack_detailed,
 	// which are not fully implemented and by default are included in
 	// transport.UnsupportedCapabilities.
@@ -73,7 +71,7 @@ func (c *Cloner) CloneRepo(opts *cmd.Options) error {
 	return cloneRevision(opts, auth, caBundle)
 }
 
-func cloneBranch(opts *cmd.Options, auth transport.AuthMethod, caBundle []byte) error {
+func cloneBranch(opts *GitCloner, auth transport.AuthMethod, caBundle []byte) error {
 	_, err := plainClone(opts.Path, false, &git.CloneOptions{
 		URL:             opts.Repo,
 		Auth:            auth,
@@ -86,7 +84,7 @@ func cloneBranch(opts *cmd.Options, auth transport.AuthMethod, caBundle []byte)
 	return err
 }
 
-func cloneRevision(opts *cmd.Options, auth transport.AuthMethod, caBundle []byte) error {
+func cloneRevision(opts *GitCloner, auth transport.AuthMethod, caBundle []byte) error {
 	r, err := plainClone(opts.Path, false, &git.CloneOptions{
 		URL:             opts.Repo,
 		Auth:            auth,
@@ -118,7 +116,7 @@ func getCABundleFromFile(path string) ([]byte, error) {
 }
 
 // createAuthFromOpts adds auth for cloning git repos based on the parameters provided in opts.
-func createAuthFromOpts(opts *cmd.Options) (transport.AuthMethod, error) {
+func createAuthFromOpts(opts *GitCloner) (transport.AuthMethod, error) {
 	if opts.SSHPrivateKeyFile != "" {
 		privateKey, err := readFile(opts.SSHPrivateKeyFile)
 		if err != nil {
diff --git a/cmd/gitcloner/cmd/cmd.go b/internal/cmd/cli/gitcloner/cmd.go
similarity index 87%
rename from cmd/gitcloner/cmd/cmd.go
rename to internal/cmd/cli/gitcloner/cmd.go
index 4a51fbf5..2d219faf 100644
--- a/cmd/gitcloner/cmd/cmd.go
+++ b/internal/cmd/cli/gitcloner/cmd.go
@@ -1,14 +1,14 @@
-package cmd
+package gitcloner
 
 import (
 	"github.com/spf13/cobra"
 )
 
-type GitCloner interface {
-	CloneRepo(opts *Options) error
+type CloneGit interface {
+	CloneRepo(opts *GitCloner) error
 }
 
-type Options struct {
+type GitCloner struct {
 	Repo              string
 	Path              string
 	Branch            string
@@ -21,9 +21,9 @@ type Options struct {
 	KnownHostsFile    string
 }
 
-var opts *Options
+var opts *GitCloner
 
-func New(gitCloner GitCloner) *cobra.Command {
+func NewCmd(gitCloner CloneGit) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "gitcloner [REPO] [PATH]",
 		Short: "Clones a git repository",
@@ -35,7 +35,7 @@ func New(gitCloner GitCloner) *cobra.Command {
 			return gitCloner.CloneRepo(opts)
 		},
 	}
-	opts = &Options{}
+	opts = &GitCloner{}
 	cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "git branch")
 	cmd.Flags().StringVar(&opts.Revision, "revision", "", "git revision")
 	cmd.Flags().StringVar(&opts.CABundleFile, "ca-bundle-file", "", "CA bundle file")
diff --git a/internal/cmd/cli/root.go b/internal/cmd/cli/root.go
index 9c855e47..3c9bb4ea 100644
--- a/internal/cmd/cli/root.go
+++ b/internal/cmd/cli/root.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/rancher/fleet/internal/client"
 	command "github.com/rancher/fleet/internal/cmd"
+	"github.com/rancher/fleet/internal/cmd/cli/gitcloner"
 	"github.com/rancher/fleet/pkg/version"
 )
 
@@ -32,6 +33,7 @@ func App() *cobra.Command {
 
 		NewTarget(),
 		NewDeploy(),
+		gitcloner.NewCmd(gitcloner.New()),
 	)
 
 	return root
diff --git a/internal/cmd/controller/agentmanagement/agent/manifest.go b/internal/cmd/controller/agentmanagement/agent/manifest.go
index 3c870d23..a09a4f62 100644
--- a/internal/cmd/controller/agentmanagement/agent/manifest.go
+++ b/internal/cmd/controller/agentmanagement/agent/manifest.go
@@ -115,9 +115,24 @@ func Manifest(namespace string, agentScope string, opts ManifestOptions) []runti
 		},
 	}
 
+	// StatefulSets require a headless service. The service is not used.
+	service := &corev1.Service{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      DefaultName,
+			Namespace: namespace,
+		},
+		Spec: corev1.ServiceSpec{
+			Selector: map[string]string{
+				"app": DefaultName,
+			},
+			Type:      corev1.ServiceTypeClusterIP,
+			ClusterIP: "None",
+		},
+	}
+
 	var objs []runtime.Object
 	objs = append(objs, clusterRole...)
-	objs = append(objs, admin, defaultSa, agent, networkPolicy)
+	objs = append(objs, admin, defaultSa, service, agent, networkPolicy)
 
 	return objs
 }
@@ -149,6 +164,7 @@ func agentApp(namespace string, agentScope string, opts ManifestOptions) *appsv1
 					"app": name,
 				},
 			},
+			ServiceName: name,
 			Template: corev1.PodTemplateSpec{
 				ObjectMeta: metav1.ObjectMeta{
 					Labels: map[string]string{
diff --git a/cmd/gitjob/main.go b/internal/cmd/controller/gitops/operator.go
similarity index 55%
rename from cmd/gitjob/main.go
rename to internal/cmd/controller/gitops/operator.go
index 891d52fd..46d89b68 100644
--- a/cmd/gitjob/main.go
+++ b/internal/cmd/controller/gitops/operator.go
@@ -1,19 +1,20 @@
 //go:generate bash ./scripts/controller-gen-generate.sh
 
-package main
+package gitops
 
 import (
 	"context"
-	"flag"
 	"fmt"
 	"net/http"
-	"os"
 	"time"
 
-	controller "github.com/rancher/fleet/internal/cmd/gitjob"
+	command "github.com/rancher/fleet/internal/cmd"
+	"github.com/rancher/fleet/internal/cmd/controller/gitops/reconciler"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	"github.com/rancher/fleet/pkg/git/poll"
+	"github.com/rancher/fleet/pkg/version"
 	"github.com/rancher/fleet/pkg/webhook"
+	"github.com/spf13/cobra"
 
 	"k8s.io/apimachinery/pkg/runtime"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
@@ -21,6 +22,7 @@ import (
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/cache"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+	clog "sigs.k8s.io/controller-runtime/pkg/log"
 	"sigs.k8s.io/controller-runtime/pkg/log/zap"
 	metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
 
@@ -30,6 +32,7 @@ import (
 var (
 	scheme   = runtime.NewScheme()
 	setupLog = ctrl.Log.WithName("setup")
+	zopts    *zap.Options
 )
 
 func init() {
@@ -37,48 +40,62 @@ func init() {
 	utilruntime.Must(fleet.AddToScheme(scheme))
 }
 
-type flags struct {
-	metricsAddr          string
-	enableLeaderElection bool
-	image                string
-	listen               string
-	debug                bool
+type GitOperator struct {
+	command.DebugConfig
+	Kubeconfig           string `usage:"Kubeconfig file"`
+	Namespace            string `usage:"namespace to watch" default:"cattle-fleet-system" env:"NAMESPACE"`
+	MetricsAddr          string `name:"metrics-bind-address" default:":8081" usage:"The address the metric endpoint binds to."`
+	EnableLeaderElection bool   `name:"leader-elect" default:"true" usage:"Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager."`
+	Image                string `name:"gitjob-image" default:"rancher/fleet:dev" usage:"The gitjob image that will be used in the generated job."`
+	Listen               string `default:":8080" usage:"The port the webhook listens."`
 }
 
-func main() {
-	ctx := ctrl.SetupSignalHandler()
-	if err := run(ctx); err != nil {
-		setupLog.Error(err, "error running gitjob")
-		os.Exit(1)
+func App(zo *zap.Options) *cobra.Command {
+	zopts = zo
+	return command.Command(&GitOperator{}, cobra.Command{
+		Version: version.FriendlyVersion(),
+		Use:     "gitjob",
+	})
+}
+
+func (g *GitOperator) PersistentPre(_ *cobra.Command, _ []string) error {
+	if err := g.SetupDebug(); err != nil {
+		return fmt.Errorf("failed to setup debug logging: %w", err)
 	}
+
+	return nil
 }
 
-func run(ctx context.Context) error {
-	namespace := os.Getenv("NAMESPACE")
-	flags := bindFlags()
+func (g *GitOperator) Run(cmd *cobra.Command, args []string) error {
+	ctx := clog.IntoContext(cmd.Context(), ctrl.Log)
+	// TODO for compatibility, override zap opts with legacy debug opts. remove once manifests are updated.
+	zopts.Development = g.Debug
+	ctrl.SetLogger(zap.New(zap.UseFlagOptions(zopts)))
+
+	namespace := g.Namespace
 	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
 		Scheme: scheme,
 		Metrics: metricsserver.Options{
-			BindAddress: flags.metricsAddr,
+			BindAddress: g.MetricsAddr,
 		},
-		LeaderElection:          flags.enableLeaderElection,
+		LeaderElection:          g.EnableLeaderElection,
 		LeaderElectionID:        "gitjob-leader",
 		LeaderElectionNamespace: namespace,
 	})
 	if err != nil {
 		return err
 	}
-	reconciler := &controller.GitJobReconciler{
+	reconciler := &reconciler.GitJobReconciler{
 		Client:    mgr.GetClient(),
 		Scheme:    mgr.GetScheme(),
-		Image:     flags.image,
+		Image:     g.Image,
 		GitPoller: poll.NewHandler(ctx, mgr.GetClient()),
 		Log:       ctrl.Log.WithName("gitjob-reconciler"),
 	}
 
 	group := errgroup.Group{}
 	group.Go(func() error {
-		return startWebhook(ctx, namespace, flags.listen, mgr.GetClient(), mgr.GetCache())
+		return startWebhook(ctx, namespace, g.Listen, mgr.GetClient(), mgr.GetCache())
 	})
 	group.Go(func() error {
 		setupLog.Info("starting manager")
@@ -92,40 +109,6 @@ func run(ctx context.Context) error {
 	return group.Wait()
 }
 
-func bindFlags() flags {
-	var metricsAddr string
-	var enableLeaderElection bool
-	var image string
-	var listen string
-	var debug bool
-	flag.StringVar(&metricsAddr, "metrics-bind-address", ":8081", "The address the metric endpoint binds to.")
-	flag.StringVar(
-		&image,
-		"gitjob-image",
-		"rancher/fleet:dev",
-		"The gitjob image that will be used in the generated job.",
-	)
-	flag.StringVar(&listen, "listen", ":8080", "The port the webhook listens.")
-	flag.BoolVar(&enableLeaderElection, "leader-elect", true,
-		"Enable leader election for controller manager. "+
-			"Enabling this will ensure there is only one active controller manager.")
-	flag.BoolVar(&debug, "debug", false, "debug mode.")
-	opts := zap.Options{
-		Development: debug,
-	}
-	opts.BindFlags(flag.CommandLine)
-	ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
-	flag.Parse()
-
-	return flags{
-		metricsAddr:          metricsAddr,
-		enableLeaderElection: enableLeaderElection,
-		image:                image,
-		listen:               listen,
-		debug:                debug,
-	}
-}
-
 func startWebhook(ctx context.Context, namespace string, addr string, client client.Client, cacheClient cache.Cache) error {
 	setupLog.Info("Setting up webhook listener")
 	handler, err := webhook.HandleHooks(ctx, namespace, client, cacheClient)
diff --git a/internal/cmd/gitjob/gitjob.go b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
similarity index 99%
rename from internal/cmd/gitjob/gitjob.go
rename to internal/cmd/controller/gitops/reconciler/gitjob_controller.go
index 292ae0ce..00d8a32d 100644
--- a/internal/cmd/gitjob/gitjob.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
@@ -1,4 +1,4 @@
-package gitjob
+package reconciler
 
 import (
 	"context"
@@ -14,9 +14,11 @@ import (
 
 	grutil "github.com/rancher/fleet/internal/cmd/controller/gitrepo"
 	v1alpha1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+
 	"github.com/rancher/wrangler/v2/pkg/condition"
 	"github.com/rancher/wrangler/v2/pkg/kstatus"
 	"github.com/rancher/wrangler/v2/pkg/name"
+
 	batchv1 "k8s.io/api/batch/v1"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
@@ -610,7 +612,7 @@ func volumesFromSecret(
 }
 
 func (r *GitJobReconciler) generateInitContainer(ctx context.Context, obj *v1alpha1.GitRepo) (corev1.Container, error) {
-	args := []string{obj.Spec.Repo, "/workspace"}
+	args := []string{"gitcloner", obj.Spec.Repo, "/workspace"}
 	volumeMounts := []corev1.VolumeMount{
 		{
 			Name:      gitClonerVolumeName,
@@ -673,7 +675,7 @@ func (r *GitJobReconciler) generateInitContainer(ctx context.Context, obj *v1alp
 
 	return corev1.Container{
 		Command: []string{
-			"gitcloner",
+			"fleet",
 		},
 		Args:         args,
 		Image:        r.Image,
diff --git a/internal/cmd/controller/operator.go b/internal/cmd/controller/operator.go
index 064bd05c..6216f81a 100644
--- a/internal/cmd/controller/operator.go
+++ b/internal/cmd/controller/operator.go
@@ -2,12 +2,14 @@ package controller
 
 import (
 	"context"
+	"fmt"
 
 	"github.com/reugn/go-quartz/quartz"
 
 	"github.com/rancher/fleet/internal/cmd/controller/reconciler"
 	"github.com/rancher/fleet/internal/cmd/controller/target"
 	"github.com/rancher/fleet/internal/manifest"
+	"github.com/rancher/fleet/internal/metrics"
 	"github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 
 	"k8s.io/apimachinery/pkg/runtime"
@@ -29,16 +31,41 @@ func init() {
 	//+kubebuilder:scaffold:scheme
 }
 
-func start(ctx context.Context, systemNamespace string, config *rest.Config, leaderOpts LeaderElectionOptions, bindAddresses BindAddresses, disableGitops bool) error {
-	setupLog.Info("listening for changes on local cluster", "disableGitops", disableGitops)
+func start(
+	ctx context.Context,
+	systemNamespace string,
+	config *rest.Config,
+	leaderOpts LeaderElectionOptions,
+	bindAddresses BindAddresses,
+	disableGitops bool,
+	disableMetrics bool,
+	shardID string,
+) error {
+	setupLog.Info("listening for changes on local cluster",
+		"disableGitops", disableGitops,
+		"disableMetrics", disableMetrics,
+	)
+
+	var metricServerOptions metricsserver.Options
+	if disableMetrics {
+		metricServerOptions = metricsserver.Options{BindAddress: "0"}
+	} else {
+		metricServerOptions = metricsserver.Options{BindAddress: bindAddresses.Metrics}
+		metrics.RegisterMetrics() // enable fleet related metrics
+	}
+
+	var leaderElectionSuffix string
+	if shardID != "" {
+		leaderElectionSuffix = fmt.Sprintf("-%s", shardID)
+	}
 
 	mgr, err := ctrl.NewManager(config, ctrl.Options{
 		Scheme:                 scheme,
-		Metrics:                metricsserver.Options{BindAddress: bindAddresses.Metrics},
+		Metrics:                metricServerOptions,
 		HealthProbeBindAddress: bindAddresses.HealthProbe,
 
 		LeaderElection:          true,
-		LeaderElectionID:        "fleet-controller-leader-election",
+		LeaderElectionID:        fmt.Sprintf("fleet-controller-leader-election-shard%s", leaderElectionSuffix),
 		LeaderElectionNamespace: systemNamespace,
 		LeaseDuration:           leaderOpts.LeaseDuration,
 		RenewDeadline:           leaderOpts.RenewDeadline,
@@ -55,6 +82,7 @@ func start(ctx context.Context, systemNamespace string, config *rest.Config, lea
 		Scheme: mgr.GetScheme(),
 
 		SystemNamespace: systemNamespace,
+		ShardID:         shardID,
 	}).SetupWithManager(mgr); err != nil {
 		setupLog.Error(err, "unable to create controller", "controller", "ConfigMap")
 		return err
@@ -68,7 +96,8 @@ func start(ctx context.Context, systemNamespace string, config *rest.Config, lea
 		Client: mgr.GetClient(),
 		Scheme: mgr.GetScheme(),
 
-		Query: builder,
+		Query:   builder,
+		ShardID: shardID,
 	}).SetupWithManager(mgr); err != nil {
 		setupLog.Error(err, "unable to create controller", "controller", "Cluster")
 		return err
@@ -81,6 +110,7 @@ func start(ctx context.Context, systemNamespace string, config *rest.Config, lea
 		Builder: builder,
 		Store:   store,
 		Query:   builder,
+		ShardID: shardID,
 	}).SetupWithManager(mgr); err != nil {
 		setupLog.Error(err, "unable to create controller", "controller", "Bundle")
 		return err
@@ -94,6 +124,7 @@ func start(ctx context.Context, systemNamespace string, config *rest.Config, lea
 			Scheme: mgr.GetScheme(),
 
 			Scheduler: sched,
+			ShardID:   shardID,
 		}).SetupWithManager(mgr); err != nil {
 			setupLog.Error(err, "unable to create controller", "controller", "GitRepo")
 			return err
@@ -102,16 +133,18 @@ func start(ctx context.Context, systemNamespace string, config *rest.Config, lea
 
 	// controllers that update status.display
 	if err = (&reconciler.ClusterGroupReconciler{
-		Client: mgr.GetClient(),
-		Scheme: mgr.GetScheme(),
+		Client:  mgr.GetClient(),
+		Scheme:  mgr.GetScheme(),
+		ShardID: shardID,
 	}).SetupWithManager(mgr); err != nil {
 		setupLog.Error(err, "unable to create controller", "controller", "ClusterGroup")
 		return err
 	}
 
 	if err = (&reconciler.BundleDeploymentReconciler{
-		Client: mgr.GetClient(),
-		Scheme: mgr.GetScheme(),
+		Client:  mgr.GetClient(),
+		Scheme:  mgr.GetScheme(),
+		ShardID: shardID,
 	}).SetupWithManager(mgr); err != nil {
 		setupLog.Error(err, "unable to create controller", "controller", "BundleDeployment")
 		return err
@@ -123,6 +156,7 @@ func start(ctx context.Context, systemNamespace string, config *rest.Config, lea
 		Scheme: mgr.GetScheme(),
 
 		Scheduler: sched,
+		ShardID:   shardID,
 	}).SetupWithManager(mgr); err != nil {
 		setupLog.Error(err, "unable to create controller", "controller", "ImageScan")
 		return err
diff --git a/internal/cmd/controller/reconciler/bundle_controller.go b/internal/cmd/controller/reconciler/bundle_controller.go
index 01952ad1..3ebffb4a 100644
--- a/internal/cmd/controller/reconciler/bundle_controller.go
+++ b/internal/cmd/controller/reconciler/bundle_controller.go
@@ -8,7 +8,9 @@ import (
 	"github.com/rancher/fleet/internal/cmd/controller/summary"
 	"github.com/rancher/fleet/internal/cmd/controller/target"
 	"github.com/rancher/fleet/internal/manifest"
+	"github.com/rancher/fleet/internal/metrics"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	"github.com/rancher/fleet/pkg/sharding"
 
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/api/meta"
@@ -46,6 +48,7 @@ type BundleReconciler struct {
 	Builder TargetBuilder
 	Store   Store
 	Query   BundleQuery
+	ShardID string
 }
 
 //+kubebuilder:rbac:groups=fleet.cattle.io,resources=bundles,verbs=get;list;watch;create;update;patch;delete
@@ -60,6 +63,8 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 	bundle := &fleet.Bundle{}
 	err := r.Get(ctx, req.NamespacedName, bundle)
 	if apierrors.IsNotFound(err) {
+		metrics.BundleCollector.Delete(req.Name, req.Namespace)
+
 		logger.V(1).Info("Bundle not found, purging bundle deployments")
 		if err := purgeBundleDeployments(ctx, r.Client, req.NamespacedName); err != nil {
 			return ctrl.Result{}, client.IgnoreNotFound(err)
@@ -110,19 +115,16 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 	}
 
 	if err := resetStatus(&bundle.Status, matchedTargets); err != nil {
-		updateDisplay(&bundle.Status)
 		return ctrl.Result{}, err
 	}
 
 	// this will add the defaults for a new bundledeployment
 	if err := target.UpdatePartitions(&bundle.Status, matchedTargets); err != nil {
-		updateDisplay(&bundle.Status)
 		return ctrl.Result{}, err
 	}
 
 	if bundle.Status.ObservedGeneration != bundle.Generation {
 		if err := setResourceKey(context.Background(), &bundle.Status, bundle, manifest, r.isNamespaced); err != nil {
-			updateDisplay(&bundle.Status)
 			return ctrl.Result{}, err
 		}
 	}
@@ -159,6 +161,7 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 	}
 
 	updateDisplay(&bundle.Status)
+
 	err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
 		t := &fleet.Bundle{}
 		err := r.Get(ctx, req.NamespacedName, t)
@@ -170,6 +173,8 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 	})
 	if err != nil {
 		logger.V(1).Error(err, "Reconcile failed final update to bundle status", "status", bundle.Status)
+	} else {
+		metrics.BundleCollector.Collect(ctx, bundle)
 	}
 
 	return ctrl.Result{}, err
@@ -252,5 +257,6 @@ func (r *BundleReconciler) SetupWithManager(mgr ctrl.Manager) error {
 			}),
 			builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
 		).
+		WithEventFilter(sharding.FilterByShardID(r.ShardID)).
 		Complete(r)
 }
diff --git a/internal/cmd/controller/reconciler/bundledeployment_controller.go b/internal/cmd/controller/reconciler/bundledeployment_controller.go
index bc358f2b..419c715d 100644
--- a/internal/cmd/controller/reconciler/bundledeployment_controller.go
+++ b/internal/cmd/controller/reconciler/bundledeployment_controller.go
@@ -7,7 +7,9 @@ import (
 	"reflect"
 
 	"github.com/rancher/fleet/internal/cmd/controller/summary"
+	"github.com/rancher/fleet/internal/metrics"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	"github.com/rancher/fleet/pkg/sharding"
 	"github.com/rancher/wrangler/v2/pkg/genericcondition"
 
 	"k8s.io/apimachinery/pkg/runtime"
@@ -22,7 +24,8 @@ import (
 // BundleDeploymentReconciler reconciles a BundleDeployment object
 type BundleDeploymentReconciler struct {
 	client.Client
-	Scheme *runtime.Scheme
+	Scheme  *runtime.Scheme
+	ShardID string
 }
 
 //+kubebuilder:rbac:groups=fleet.cattle.io,resources=bundledeployments,verbs=get;list;watch;create;update;patch;delete
@@ -37,6 +40,7 @@ func (r *BundleDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Req
 	bd := &fleet.BundleDeployment{}
 	err := r.Get(ctx, req.NamespacedName, bd)
 	if err != nil {
+		metrics.BundleDeploymentCollector.Delete(req.Name, req.Namespace)
 		return ctrl.Result{}, client.IgnoreNotFound(err)
 	}
 	// increased log level, this triggers a lot
@@ -61,8 +65,9 @@ func (r *BundleDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Req
 		State:     string(summary.GetDeploymentState(bd)),
 	}
 
+	var t *fleet.BundleDeployment
 	err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
-		t := &fleet.BundleDeployment{}
+		t = &fleet.BundleDeployment{}
 		err := r.Get(ctx, req.NamespacedName, t)
 		if err != nil {
 			return err
@@ -72,6 +77,8 @@ func (r *BundleDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Req
 	})
 	if err != nil {
 		logger.V(1).Error(err, "Reconcile failed final update to bundle deployment status", "status", bd.Status)
+	} else {
+		metrics.BundleDeploymentCollector.Collect(ctx, t)
 	}
 
 	return ctrl.Result{}, err
@@ -99,7 +106,10 @@ func bundleDeploymentStatusChangedPredicate() predicate.Funcs {
 func (r *BundleDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewControllerManagedBy(mgr).
 		For(&fleet.BundleDeployment{}).
-		WithEventFilter(bundleDeploymentStatusChangedPredicate()).
+		WithEventFilter(predicate.And(
+			sharding.FilterByShardID(r.ShardID),
+			bundleDeploymentStatusChangedPredicate(),
+		)).
 		Complete(r)
 }
 
diff --git a/internal/cmd/controller/reconciler/cluster_controller.go b/internal/cmd/controller/reconciler/cluster_controller.go
index febdc444..9de92fef 100644
--- a/internal/cmd/controller/reconciler/cluster_controller.go
+++ b/internal/cmd/controller/reconciler/cluster_controller.go
@@ -9,8 +9,10 @@ import (
 	"time"
 
 	"github.com/rancher/fleet/internal/cmd/controller/summary"
+	"github.com/rancher/fleet/internal/metrics"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	"github.com/rancher/fleet/pkg/durations"
+	"github.com/rancher/fleet/pkg/sharding"
 	"github.com/sirupsen/logrus"
 
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -40,7 +42,8 @@ type ClusterReconciler struct {
 	client.Client
 	Scheme *runtime.Scheme
 
-	Query BundleQuery
+	Query   BundleQuery
+	ShardID string
 }
 
 //+kubebuilder:rbac:groups=fleet.cattle.io,resources=clusters,verbs=get;list;watch;create;update;patch;delete
@@ -55,6 +58,7 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 	cluster := &fleet.Cluster{}
 	err := r.Get(ctx, req.NamespacedName, cluster)
 	if apierrors.IsNotFound(err) {
+		metrics.ClusterCollector.Delete(req.Name, req.Namespace)
 		return ctrl.Result{}, nil
 	} else if err != nil {
 		return ctrl.Result{}, err
@@ -161,6 +165,8 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 	})
 	if err != nil {
 		logger.V(1).Error(err, "Reconcile failed final update to cluster status", "status", cluster.Status)
+	} else {
+		metrics.ClusterCollector.Collect(ctx, cluster)
 	}
 
 	if allReady && cluster.Status.ResourceCounts.Ready != cluster.Status.ResourceCounts.DesiredReady {
@@ -180,6 +186,7 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewControllerManagedBy(mgr).
 		For(&fleet.Cluster{}).
+		WithEventFilter(sharding.FilterByShardID(r.ShardID)).
 		// Note: Maybe we can tune events after cleanup code is
 		// removed? This relies on bundledeployments and gitrepos to
 		// update its status. It also needs to trigger on
diff --git a/internal/cmd/controller/reconciler/clustergroup_controller.go b/internal/cmd/controller/reconciler/clustergroup_controller.go
index aec0166d..f518d44c 100644
--- a/internal/cmd/controller/reconciler/clustergroup_controller.go
+++ b/internal/cmd/controller/reconciler/clustergroup_controller.go
@@ -8,7 +8,9 @@ import (
 	"reflect"
 	"strings"
 
+	"github.com/rancher/fleet/internal/metrics"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	"github.com/rancher/fleet/pkg/sharding"
 
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/client-go/util/retry"
@@ -22,7 +24,8 @@ import (
 // ClusterGroupReconciler reconciles a ClusterGroup object
 type ClusterGroupReconciler struct {
 	client.Client
-	Scheme *runtime.Scheme
+	Scheme  *runtime.Scheme
+	ShardID string
 }
 
 //+kubebuilder:rbac:groups=fleet.cattle.io,resources=clustergroups,verbs=get;list;watch;create;update;patch;delete
@@ -37,6 +40,7 @@ func (r *ClusterGroupReconciler) Reconcile(ctx context.Context, req ctrl.Request
 	group := &fleet.ClusterGroup{}
 	err := r.Get(ctx, req.NamespacedName, group)
 	if err != nil {
+		metrics.ClusterGroupCollector.Delete(req.Name, req.Namespace)
 		return ctrl.Result{}, client.IgnoreNotFound(err)
 	}
 	logger.V(1).Info("Reconciling clustergroup, updating display status field", "oldDisplay", group.Status.Display)
@@ -71,6 +75,8 @@ func (r *ClusterGroupReconciler) Reconcile(ctx context.Context, req ctrl.Request
 	})
 	if err != nil {
 		logger.V(1).Error(err, "Reconcile failed final update to cluster group status", "status", group.Status)
+	} else {
+		metrics.ClusterGroupCollector.Collect(ctx, group)
 	}
 
 	return ctrl.Result{}, err
@@ -81,20 +87,23 @@ func (r *ClusterGroupReconciler) SetupWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewControllerManagedBy(mgr).
 		For(&fleet.ClusterGroup{}).
 		WithEventFilter(
-			// only trigger on status changes, create
-			predicate.Funcs{
-				CreateFunc: func(e event.CreateEvent) bool {
-					return true
+			predicate.And(
+				sharding.FilterByShardID(r.ShardID),
+				// only trigger on status changes, create
+				predicate.Funcs{
+					CreateFunc: func(e event.CreateEvent) bool {
+						return true
+					},
+					UpdateFunc: func(e event.UpdateEvent) bool {
+						n := e.ObjectNew.(*fleet.ClusterGroup)
+						o := e.ObjectOld.(*fleet.ClusterGroup)
+						if n == nil || o == nil {
+							return false
+						}
+						return !reflect.DeepEqual(n.Status, o.Status)
+					},
 				},
-				UpdateFunc: func(e event.UpdateEvent) bool {
-					n := e.ObjectNew.(*fleet.ClusterGroup)
-					o := e.ObjectOld.(*fleet.ClusterGroup)
-					if n == nil || o == nil {
-						return false
-					}
-					return !reflect.DeepEqual(n.Status, o.Status)
-				},
-			},
+			),
 		).
 		Complete(r)
 }
diff --git a/internal/cmd/controller/reconciler/config_controller.go b/internal/cmd/controller/reconciler/config_controller.go
index 7d57bee0..bf03830b 100644
--- a/internal/cmd/controller/reconciler/config_controller.go
+++ b/internal/cmd/controller/reconciler/config_controller.go
@@ -5,6 +5,7 @@ import (
 	"context"
 
 	"github.com/rancher/fleet/internal/config"
+	"github.com/rancher/fleet/pkg/sharding"
 
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -22,6 +23,7 @@ type ConfigReconciler struct {
 	Scheme *runtime.Scheme
 
 	SystemNamespace string
+	ShardID         string
 }
 
 // Load the config from the configmap and set it in the config package.
@@ -73,6 +75,7 @@ func (r *ConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
 		WithEventFilter(
 			// we do not trigger for status changes
 			predicate.And(
+				sharding.FilterByShardID(r.ShardID),
 				predicate.NewPredicateFuncs(func(object client.Object) bool {
 					return object.GetNamespace() == r.SystemNamespace &&
 						object.GetName() == config.ManagerConfigName
diff --git a/internal/cmd/controller/reconciler/gitrepo_controller.go b/internal/cmd/controller/reconciler/gitrepo_controller.go
index f8f52874..36aaf3e5 100644
--- a/internal/cmd/controller/reconciler/gitrepo_controller.go
+++ b/internal/cmd/controller/reconciler/gitrepo_controller.go
@@ -9,7 +9,9 @@ import (
 
 	grutil "github.com/rancher/fleet/internal/cmd/controller/gitrepo"
 	"github.com/rancher/fleet/internal/cmd/controller/imagescan"
+	"github.com/rancher/fleet/internal/metrics"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	"github.com/rancher/fleet/pkg/sharding"
 	"github.com/reugn/go-quartz/quartz"
 
 	"github.com/rancher/wrangler/v2/pkg/condition"
@@ -37,6 +39,7 @@ type GitRepoReconciler struct {
 	Scheme *runtime.Scheme
 
 	Scheduler quartz.Scheduler
+	ShardID   string
 }
 
 //+kubebuilder:rbac:groups=fleet.cattle.io,resources=gitrepos,verbs=get;list;watch;create;update;patch;delete
@@ -57,6 +60,9 @@ func (r *GitRepoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 	// Clean up
 	if apierrors.IsNotFound(err) {
 		logger.V(1).Info("Gitrepo deleted, deleting bundle, image scans")
+
+		metrics.GitRepoCollector.Delete(req.NamespacedName.Name, req.NamespacedName.Namespace)
+
 		if err := purgeBundles(ctx, r.Client, req.NamespacedName); err != nil {
 			return ctrl.Result{}, err
 		}
@@ -76,6 +82,8 @@ func (r *GitRepoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 	gitrepo.Status.ObservedGeneration = gitrepo.Generation
 
 	if gitrepo.Spec.Repo == "" {
+		metrics.GitRepoCollector.Collect(ctx, gitrepo)
+
 		return ctrl.Result{}, nil
 	}
 
@@ -115,7 +123,10 @@ func (r *GitRepoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 	err = r.updateStatus(ctx, req.NamespacedName, gitrepo.Status)
 	if err != nil {
 		logger.V(1).Error(err, "Reconcile failed final update to git repo status", "status", gitrepo.Status)
+
 		return ctrl.Result{}, err
+	} else {
+		metrics.GitRepoCollector.Collect(ctx, gitrepo)
 	}
 
 	// Validate external secrets exist
@@ -177,6 +188,7 @@ func (r *GitRepoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 		return ctrl.Result{}, err
 	}
 
+	metrics.GitRepoCollector.Collect(ctx, gitrepo)
 	return ctrl.Result{}, nil
 }
 
@@ -226,11 +238,14 @@ func (r *GitRepoReconciler) SetupWithManager(mgr ctrl.Manager) error {
 		).
 		WithEventFilter(
 			// do not trigger for GitRepo status changes
-			predicate.Or(
-				bundleStatusChangedPredicate(),
-				predicate.GenerationChangedPredicate{},
-				predicate.AnnotationChangedPredicate{},
-				predicate.LabelChangedPredicate{},
+			predicate.And(
+				sharding.FilterByShardID(r.ShardID),
+				predicate.Or(
+					bundleStatusChangedPredicate(),
+					predicate.GenerationChangedPredicate{},
+					predicate.AnnotationChangedPredicate{},
+					predicate.LabelChangedPredicate{},
+				),
 			),
 		).
 		Complete(r)
diff --git a/internal/cmd/controller/reconciler/imagescan_controller.go b/internal/cmd/controller/reconciler/imagescan_controller.go
index c62ea88d..a010e534 100644
--- a/internal/cmd/controller/reconciler/imagescan_controller.go
+++ b/internal/cmd/controller/reconciler/imagescan_controller.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/rancher/fleet/internal/cmd/controller/imagescan"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	"github.com/rancher/fleet/pkg/sharding"
 
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -24,6 +25,7 @@ type ImageScanReconciler struct {
 	Scheme *runtime.Scheme
 
 	Scheduler quartz.Scheduler
+	ShardID   string
 }
 
 //+kubebuilder:rbac:groups=fleet.cattle.io,resources=clusters,verbs=get;list;watch;create;update;patch;delete
@@ -100,10 +102,13 @@ func (r *ImageScanReconciler) SetupWithManager(mgr ctrl.Manager) error {
 		For(&fleet.ImageScan{}).
 		WithEventFilter(
 			// we do not trigger for status changes
-			predicate.Or(
-				predicate.GenerationChangedPredicate{},
-				predicate.AnnotationChangedPredicate{},
-				predicate.LabelChangedPredicate{},
+			predicate.And(
+				sharding.FilterByShardID(r.ShardID),
+				predicate.Or(
+					predicate.GenerationChangedPredicate{},
+					predicate.AnnotationChangedPredicate{},
+					predicate.LabelChangedPredicate{},
+				),
 			)).
 		Complete(r)
 }
diff --git a/internal/cmd/controller/root.go b/internal/cmd/controller/root.go
index 7219292f..bb1c19a2 100644
--- a/internal/cmd/controller/root.go
+++ b/internal/cmd/controller/root.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/rancher/fleet/internal/cmd/controller/agentmanagement"
 	"github.com/rancher/fleet/internal/cmd/controller/agentmanagement/agent"
+	"github.com/rancher/fleet/internal/cmd/controller/gitops"
 
 	"github.com/spf13/cobra"
 
@@ -31,9 +32,11 @@ import (
 
 type FleetManager struct {
 	command.DebugConfig
-	Kubeconfig    string `usage:"Kubeconfig file"`
-	Namespace     string `usage:"namespace to watch" default:"cattle-fleet-system" env:"NAMESPACE"`
-	DisableGitops bool   `usage:"disable gitops components" name:"disable-gitops"`
+	Kubeconfig     string `usage:"Kubeconfig file"`
+	Namespace      string `usage:"namespace to watch" default:"cattle-fleet-system" env:"NAMESPACE"`
+	DisableGitops  bool   `usage:"disable gitops components" name:"disable-gitops"`
+	DisableMetrics bool   `usage:"disable metrics" name:"disable-metrics"`
+	ShardID        string `usage:"only manage resources labeled with a specific shard ID" name:"shard-id"`
 }
 
 type LeaderElectionOptions struct {
@@ -128,7 +131,16 @@ func (f *FleetManager) Run(cmd *cobra.Command, args []string) error {
 	go func() {
 		log.Println(http.ListenAndServe("localhost:6060", nil)) // nolint:gosec // Debugging only
 	}()
-	if err := start(ctx, f.Namespace, kubeconfig, leaderOpts, bindAddresses, f.DisableGitops); err != nil {
+	if err := start(
+		ctx,
+		f.Namespace,
+		kubeconfig,
+		leaderOpts,
+		bindAddresses,
+		f.DisableGitops,
+		f.DisableMetrics,
+		f.ShardID,
+	); err != nil {
 		return err
 	}
 
@@ -145,8 +157,11 @@ func App() *cobra.Command {
 	ctrl.RegisterFlags(fs)
 	root.Flags().AddGoFlagSet(fs)
 
-	root.AddCommand(cleanup.App())
-	root.AddCommand(agentmanagement.App())
+	root.AddCommand(
+		cleanup.App(),
+		agentmanagement.App(),
+		gitops.App(&zopts),
+	)
 	return root
 }
 
diff --git a/internal/cmd/controller/summary/summary.go b/internal/cmd/controller/summary/summary.go
index 59c93a6d..e81e890e 100644
--- a/internal/cmd/controller/summary/summary.go
+++ b/internal/cmd/controller/summary/summary.go
@@ -72,6 +72,8 @@ func IncrementResourceCounts(left *fleet.GitRepoResourceCounts, right fleet.GitR
 	left.NotReady += right.NotReady
 }
 
+// GetSummaryState returns the summary state of a bundle. The returned value is
+// empty if the bundle has no non-ready resources.
 func GetSummaryState(summary fleet.BundleSummary) fleet.BundleState {
 	var state fleet.BundleState
 	for _, nonReady := range summary.NonReadyResources {
diff --git a/internal/metrics/bundle_metrics.go b/internal/metrics/bundle_metrics.go
new file mode 100644
index 00000000..14703a1f
--- /dev/null
+++ b/internal/metrics/bundle_metrics.go
@@ -0,0 +1,147 @@
+package metrics
+
+import (
+	"fmt"
+
+	"github.com/rancher/fleet/internal/cmd/controller/summary"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var (
+	bundleSubsystem = "bundle"
+	bundleLabels    = []string{"name", "namespace", "commit", "repo", "generation", "state"}
+	BundleCollector = CollectorCollection{
+		subsystem: bundleSubsystem,
+		metrics: map[string]prometheus.Collector{
+			"not_ready": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleSubsystem,
+					Name:      "not_ready",
+					Help:      "Number of deployments for a specific bundle in a not ready state.",
+				},
+				bundleLabels,
+			),
+			"wait_applied": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleSubsystem,
+					Name:      "wait_applied",
+					Help:      "Number of deployments for a specific bundle in a wait applied state.",
+				},
+				bundleLabels,
+			),
+			"err_applied": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleSubsystem,
+					Name:      "err_applied",
+					Help:      "Number of deployments for a specific bundle in an error applied state.",
+				},
+				bundleLabels,
+			),
+			"out_of_sync": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleSubsystem,
+					Name:      "out_of_sync",
+					Help:      "Number of deployments for a specific bundle in an out of sync state.",
+				},
+				bundleLabels,
+			),
+			"modified": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleSubsystem,
+					Name:      "modified",
+					Help:      "Number of deployments for a specific bundle in a modified state.",
+				},
+				bundleLabels,
+			),
+			"ready": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleSubsystem,
+					Name:      "ready",
+					Help:      "Number of deployments for a specific bundle in a ready state.",
+				},
+				bundleLabels,
+			),
+			"pending": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleSubsystem,
+					Name:      "pending",
+					Help:      "Number of deployments for a specific bundle in a pending state.",
+				},
+				bundleLabels,
+			),
+			"desired_ready": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleSubsystem,
+					Name:      "desired_ready",
+					Help:      "Number of deployments that are desired to be ready for a bundle.",
+				},
+				bundleLabels,
+			),
+			"state": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleSubsystem,
+					Name:      "state",
+					Help:      "Shows the state of this bundle based on the state label. A value of 1 is true 0, is false.",
+				},
+				bundleLabels,
+			),
+		},
+		collector: collectBundleMetrics,
+	}
+)
+
+func collectBundleMetrics(obj any, metrics map[string]prometheus.Collector) {
+	bundle, ok := obj.(*fleet.Bundle)
+	if !ok {
+		panic("unexpected object type")
+	}
+
+	currentState := summary.GetSummaryState(bundle.Status.Summary)
+	labels := prometheus.Labels{
+		"name":       bundle.Name,
+		"namespace":  bundle.Namespace,
+		"commit":     bundle.ObjectMeta.Labels[commitLabel],
+		"repo":       bundle.ObjectMeta.Labels[repoNameLabel],
+		"generation": fmt.Sprintf("%d", bundle.ObjectMeta.Generation),
+		"state":      string(currentState),
+	}
+
+	metrics["not_ready"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(bundle.Status.Summary.NotReady))
+	metrics["wait_applied"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(bundle.Status.Summary.WaitApplied))
+	metrics["err_applied"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(bundle.Status.Summary.ErrApplied))
+	metrics["out_of_sync"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(bundle.Status.Summary.OutOfSync))
+	metrics["modified"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(bundle.Status.Summary.Modified))
+	metrics["ready"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(bundle.Status.Summary.Ready))
+	metrics["pending"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(bundle.Status.Summary.Pending))
+	metrics["desired_ready"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(bundle.Status.Summary.DesiredReady))
+
+	for _, state := range bundleStates {
+		labels["state"] = string(state)
+
+		if state == currentState {
+			metrics["state"].(*prometheus.GaugeVec).With(labels).Set(1)
+		} else {
+			metrics["state"].(*prometheus.GaugeVec).With(labels).Set(0)
+		}
+	}
+}
diff --git a/internal/metrics/bundledeployment_metrics.go b/internal/metrics/bundledeployment_metrics.go
new file mode 100644
index 00000000..92a87a74
--- /dev/null
+++ b/internal/metrics/bundledeployment_metrics.go
@@ -0,0 +1,74 @@
+package metrics
+
+import (
+	"fmt"
+
+	"github.com/rancher/fleet/internal/cmd/controller/summary"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var (
+	bundleDeploymentSubsystem = "bundledeployment"
+	bundleDeploymentLabels    = []string{
+		"name",
+		"namespace",
+		"cluster_name",
+		"cluster_namespace",
+		"repo",
+		"commit",
+		"bundle",
+		"bundle_namespace",
+		"generation",
+		"state",
+	}
+	BundleDeploymentCollector = CollectorCollection{
+		subsystem: bundleDeploymentSubsystem,
+		metrics: map[string]prometheus.Collector{
+			"state": promauto.NewGaugeVec(
+				prometheus.GaugeOpts{
+					Namespace: metricPrefix,
+					Subsystem: bundleDeploymentSubsystem,
+					Name:      "state",
+					Help: "Shows the state of this bundle deployment based on the state label. " +
+						"A value of 1 is true 0 is false.",
+				},
+				bundleDeploymentLabels,
+			),
+		},
+		collector: collectBundleDeploymentMetrics,
+	}
+)
+
+func collectBundleDeploymentMetrics(obj any, metrics map[string]prometheus.Collector) {
+	bundleDep, ok := obj.(*fleet.BundleDeployment)
+	if !ok {
+		panic("unexpected object type")
+	}
+
+	currentState := summary.GetDeploymentState(bundleDep)
+	labels := prometheus.Labels{
+		"name":              bundleDep.Name,
+		"namespace":         bundleDep.Namespace,
+		"cluster_name":      bundleDep.ObjectMeta.Labels["fleet.cattle.io/cluster"],
+		"cluster_namespace": bundleDep.ObjectMeta.Labels["fleet.cattle.io/cluster-namespace"],
+		"repo":              bundleDep.ObjectMeta.Labels[repoNameLabel],
+		"commit":            bundleDep.ObjectMeta.Labels[commitLabel],
+		"bundle":            bundleDep.ObjectMeta.Labels["fleet.cattle.io/bundle-name"],
+		"bundle_namespace":  bundleDep.ObjectMeta.Labels["fleet.cattle.io/bundle-namespace"],
+		"generation":        fmt.Sprintf("%d", bundleDep.ObjectMeta.Generation),
+		"state":             string(currentState),
+	}
+
+	for _, state := range bundleStates {
+		labels["state"] = string(state)
+
+		if state == currentState {
+			metrics["state"].(*prometheus.GaugeVec).With(labels).Set(1)
+		} else {
+			metrics["state"].(*prometheus.GaugeVec).With(labels).Set(0)
+		}
+	}
+}
diff --git a/internal/metrics/cluster_metrics.go b/internal/metrics/cluster_metrics.go
new file mode 100644
index 00000000..b155d772
--- /dev/null
+++ b/internal/metrics/cluster_metrics.go
@@ -0,0 +1,188 @@
+package metrics
+
+import (
+	"fmt"
+
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var (
+	clusterSubsystem = "cluster"
+	clusterLabels    = []string{
+		"name",
+		"namespace",
+		// The name as given per "management.cattle.io/cluster-name" label. This
+		// may but does not have to be different from `name` label and is added
+		// by Rancher.
+		"cluster_name",
+		"cluster_display_name",
+		"generation",
+		"state",
+	}
+
+	clusterNameLabel        = "management.cattle.io/cluster-name"
+	clusterDisplayNameLabel = "management.cattle.io/cluster-display-name"
+	clusterStates           = []string{
+		string(fleet.NotReady),
+		string(fleet.Ready),
+		"WaitCheckIn",
+	}
+
+	ClusterCollector = CollectorCollection{
+		clusterSubsystem,
+		clusterMetrics,
+		collectClusterMetrics,
+	}
+
+	clusterMetrics = map[string]prometheus.Collector{
+		"desired_ready_git_repos": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "desired_ready_git_repos",
+				Help:      "The desired number of GitRepos to be in a ready state.",
+			},
+			clusterLabels,
+		),
+		"ready_git_repos": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "ready_git_repos",
+				Help:      "The number of GitRepos in a ready state.",
+			},
+			clusterLabels,
+		),
+		"resources_count_desiredready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "resources_count_desiredready",
+				Help:      "The number of resources for the given cluster desired to be in the Ready state.",
+			},
+			clusterLabels,
+		),
+		"resources_count_missing": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "resources_count_missing",
+				Help:      "The number of resources in the Missing state.",
+			},
+			clusterLabels,
+		),
+		"resources_count_modified": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "resources_count_modified",
+				Help:      "The number of resources in the Modified state.",
+			},
+			clusterLabels,
+		),
+		"resources_count_notready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "resources_count_notready",
+				Help:      "The number of resources in the NotReady state.",
+			},
+			clusterLabels,
+		),
+		"resources_count_orphaned": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "resources_count_orphaned",
+				Help:      "The number of resources in the Orphaned state.",
+			},
+			clusterLabels,
+		),
+		"resources_count_ready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "resources_count_ready",
+				Help:      "The number of resources in the Ready state.",
+			},
+			clusterLabels,
+		),
+		"resources_count_unknown": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "resources_count_unknown",
+				Help:      "The number of resources in the Unknown state.",
+			},
+			clusterLabels,
+		),
+		"resources_count_waitapplied": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "resources_count_waitapplied",
+				Help:      "The number of resources in the WaitApplied state.",
+			},
+			clusterLabels,
+		),
+		"state": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "state",
+				Help:      "The current state of a given cluster",
+			},
+			clusterLabels,
+		),
+	}
+)
+
+func collectClusterMetrics(obj any, metrics map[string]prometheus.Collector) {
+	cluster, ok := obj.(*fleet.Cluster)
+	if !ok {
+		panic("unexpected object type")
+	}
+
+	labels := prometheus.Labels{
+		"name":                 cluster.Name,
+		"namespace":            cluster.Namespace,
+		"cluster_name":         cluster.ObjectMeta.Labels[clusterNameLabel],
+		"cluster_display_name": cluster.ObjectMeta.Labels[clusterDisplayNameLabel],
+		"generation":           fmt.Sprintf("%d", cluster.ObjectMeta.Generation),
+		"state":                cluster.Status.Display.State,
+	}
+
+	metrics["desired_ready_git_repos"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.DesiredReadyGitRepos))
+	metrics["ready_git_repos"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ReadyGitRepos))
+	metrics["resources_count_desiredready"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ResourceCounts.DesiredReady))
+	metrics["resources_count_missing"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ResourceCounts.Missing))
+	metrics["resources_count_modified"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ResourceCounts.Modified))
+	metrics["resources_count_notready"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ResourceCounts.NotReady))
+	metrics["resources_count_orphaned"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ResourceCounts.Orphaned))
+	metrics["resources_count_ready"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ResourceCounts.Ready))
+	metrics["resources_count_unknown"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ResourceCounts.Unknown))
+	metrics["resources_count_waitapplied"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ResourceCounts.WaitApplied))
+
+	for _, state := range clusterStates {
+		labels["state"] = state
+
+		if state == cluster.Status.Display.State {
+			metrics["state"].(*prometheus.GaugeVec).With(labels).Set(1)
+		} else {
+			metrics["state"].(*prometheus.GaugeVec).With(labels).Set(0)
+		}
+	}
+}
diff --git a/internal/metrics/clustergroup_metrics.go b/internal/metrics/clustergroup_metrics.go
new file mode 100644
index 00000000..25d718fa
--- /dev/null
+++ b/internal/metrics/clustergroup_metrics.go
@@ -0,0 +1,192 @@
+package metrics
+
+import (
+	"fmt"
+
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var (
+	clusterGroupSubsystem = "cluster_group"
+	clusterGroupLabels    = []string{"name", "namespace", "generation", "state"}
+	clusterGroupStates    = []string{
+		string(fleet.NotReady),
+		string(fleet.Ready),
+	}
+	ClusterGroupCollector = CollectorCollection{
+		clusterGroupSubsystem,
+		clusterGroupMetrics,
+		collectClusterGroupMetrics,
+	}
+	clusterGroupMetrics = map[string]prometheus.Collector{
+		"cluster_count": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "cluster_count",
+				Help:      "The count of clusters in this cluster group.",
+			},
+			clusterGroupLabels,
+		),
+		"non_ready_cluster_count": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "non_ready_cluster_count",
+				Help:      "The count of non ready clusters in this cluster group.",
+			},
+			clusterGroupLabels,
+		),
+		"resource_count_desired_ready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "resource_count_desired_ready",
+				Help:      "The count of resources that are desired to be in the Ready state.",
+			},
+			clusterGroupLabels,
+		),
+		"resource_count_missing": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "resource_count_missing",
+				Help:      "The count of resources that are in a Missing state.",
+			},
+			clusterGroupLabels,
+		),
+		"resource_count_modified": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "resource_count_modified",
+				Help:      "The count of resources that are in a Modified state.",
+			},
+			clusterGroupLabels,
+		),
+		"resource_count_notready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "resource_count_notready",
+				Help:      "The count of resources that are in a NotReady state.",
+			},
+			clusterGroupLabels,
+		),
+		"resource_count_orphaned": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "resource_count_orphaned",
+				Help:      "The count of resources that are in an Orphaned state.",
+			},
+			clusterGroupLabels,
+		),
+		"resource_count_ready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "resource_count_ready",
+				Help:      "The count of resources that are in a Ready state.",
+			},
+			clusterGroupLabels,
+		),
+		"resource_count_unknown": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "resource_count_unknown",
+				Help:      "The count of resources that are in an Unknown state.",
+			},
+			clusterGroupLabels,
+		),
+		"resource_count_waitapplied": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "resource_count_waitapplied",
+				Help:      "The count of resources that are in a WaitApplied state.",
+			},
+			clusterGroupLabels,
+		),
+		"bundle_desired_ready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "bundle_desired_ready",
+				Help:      "The count of bundles that are desired to be in a Ready state.",
+			},
+			clusterGroupLabels,
+		),
+		"bundle_ready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "bundle_ready",
+				Help:      "The count of bundles that are in a Ready state in the Cluster Group.",
+			},
+			clusterGroupLabels,
+		),
+		"state": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterGroupSubsystem,
+				Name:      "state",
+				Help:      "The current state of a given cluster group.",
+			},
+			clusterGroupLabels,
+		),
+	}
+)
+
+func collectClusterGroupMetrics(obj any, metrics map[string]prometheus.Collector) {
+	clusterGroup, ok := obj.(*fleet.ClusterGroup)
+	if !ok {
+		panic("unexpected object type")
+	}
+
+	labels := prometheus.Labels{
+		"name":       clusterGroup.Name,
+		"namespace":  clusterGroup.Namespace,
+		"generation": fmt.Sprintf("%d", clusterGroup.ObjectMeta.Generation),
+		"state":      clusterGroup.Status.Display.State,
+	}
+
+	metrics["cluster_count"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.ClusterCount))
+	metrics["non_ready_cluster_count"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.NonReadyClusterCount))
+	metrics["resource_count_desired_ready"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.ResourceCounts.DesiredReady))
+	metrics["resource_count_missing"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.ResourceCounts.Missing))
+	metrics["resource_count_modified"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.ResourceCounts.Modified))
+	metrics["resource_count_notready"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.ResourceCounts.NotReady))
+	metrics["resource_count_orphaned"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.ResourceCounts.Orphaned))
+	metrics["resource_count_ready"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.ResourceCounts.Ready))
+	metrics["resource_count_unknown"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.ResourceCounts.Unknown))
+	metrics["resource_count_waitapplied"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.ResourceCounts.WaitApplied))
+	metrics["bundle_desired_ready"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.Summary.DesiredReady))
+	metrics["bundle_ready"].(*prometheus.GaugeVec).With(labels).
+		Set(float64(clusterGroup.Status.Summary.Ready))
+
+	for _, state := range clusterGroupStates {
+		labels["state"] = state
+
+		if state == clusterGroup.Status.Display.State {
+			metrics["state"].(*prometheus.GaugeVec).With(labels).Set(1)
+		} else {
+			metrics["state"].(*prometheus.GaugeVec).With(labels).Set(0)
+		}
+	}
+}
diff --git a/internal/metrics/gitrepo_metrics.go b/internal/metrics/gitrepo_metrics.go
new file mode 100644
index 00000000..7900e076
--- /dev/null
+++ b/internal/metrics/gitrepo_metrics.go
@@ -0,0 +1,149 @@
+package metrics
+
+import (
+	"strings"
+
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+)
+
+var (
+	gitRepoSubsystem = "gitrepo"
+	gitRepoLabels    = []string{"name", "namespace", "repo", "branch", "paths"}
+	GitRepoCollector = CollectorCollection{
+		gitRepoSubsystem,
+		gitRepoMetrics,
+		collectGitRepoMetrics,
+	}
+	gitRepoMetrics = map[string]prometheus.Collector{
+		"resources_desired_ready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "resources_desired_ready",
+				Help:      "The count of resources that are desired to be in a Ready state.",
+			},
+			gitRepoLabels,
+		),
+		"resources_missing": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "resources_missing",
+				Help:      "The count of resources that are in a Missing state.",
+			},
+			gitRepoLabels,
+		),
+		"resources_modified": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "resources_modified",
+				Help:      "The count of resources that are in a Modified state.",
+			},
+			gitRepoLabels,
+		),
+		"resources_not_ready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "resources_not_ready",
+				Help:      "The count of resources that are in a NotReady state.",
+			},
+			gitRepoLabels,
+		),
+		"resources_orphaned": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "resources_orphaned",
+				Help:      "The count of resources that are in an Orphaned state.",
+			},
+			gitRepoLabels,
+		),
+		"resources_ready": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "resources_ready",
+				Help:      "The count of resources that are in a Ready state.",
+			},
+			gitRepoLabels,
+		),
+		"resources_unknown": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "resources_unknown",
+				Help:      "The count of resources that are in an Unknown state.",
+			},
+			gitRepoLabels,
+		),
+		"resources_wait_applied": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "resources_wait_applied",
+				Help:      "The count of resources that are in a WaitApplied state.",
+			},
+			gitRepoLabels,
+		),
+		"desired_ready_clusters": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "desired_ready_clusters",
+				Help:      "The amount of clusters desired to be in a ready state.",
+			},
+			gitRepoLabels,
+		),
+		"ready_clusters": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: gitRepoSubsystem,
+				Name:      "ready_clusters",
+				Help:      "The count of clusters in a Ready state.",
+			},
+			gitRepoLabels,
+		),
+	}
+	collectGitRepoMetrics = func(
+		obj any,
+		metrics map[string]prometheus.Collector,
+	) {
+		gitrepo, ok := obj.(*fleet.GitRepo)
+		if !ok {
+			panic("unexpected object type")
+		}
+
+		labels := prometheus.Labels{
+			"name":      gitrepo.Name,
+			"namespace": gitrepo.Namespace,
+			"repo":      gitrepo.Spec.Repo,
+			"branch":    gitrepo.Spec.Branch,
+			"paths":     strings.Join(gitrepo.Spec.Paths, ";"),
+		}
+
+		metrics["desired_ready_clusters"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.DesiredReadyClusters))
+		metrics["ready_clusters"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.ReadyClusters))
+		metrics["resources_missing"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.ResourceCounts.Missing))
+		metrics["resources_modified"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.ResourceCounts.Modified))
+		metrics["resources_not_ready"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.ResourceCounts.NotReady))
+		metrics["resources_orphaned"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.ResourceCounts.Orphaned))
+		metrics["resources_desired_ready"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.ResourceCounts.DesiredReady))
+		metrics["resources_ready"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.ResourceCounts.Ready))
+		metrics["resources_unknown"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.ResourceCounts.Unknown))
+		metrics["resources_wait_applied"].(*prometheus.GaugeVec).
+			With(labels).Set(float64(gitrepo.Status.ResourceCounts.WaitApplied))
+	}
+)
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
new file mode 100644
index 00000000..45d379ac
--- /dev/null
+++ b/internal/metrics/metrics.go
@@ -0,0 +1,105 @@
+package metrics
+
+import (
+	"context"
+	"errors"
+
+	"github.com/prometheus/client_golang/prometheus"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/log"
+	"sigs.k8s.io/controller-runtime/pkg/metrics"
+)
+
+const (
+	metricPrefix  = "fleet"
+	commitLabel   = "fleet.cattle.io/commit"
+	repoNameLabel = "fleet.cattle.io/repo-name"
+)
+
+var (
+	bundleStates = []fleet.BundleState{
+		fleet.Ready,
+		fleet.NotReady,
+		fleet.Pending,
+		fleet.OutOfSync,
+		fleet.Modified,
+		fleet.WaitApplied,
+		fleet.ErrApplied,
+	}
+	enabled = false
+)
+
+func RegisterMetrics() {
+	enabled = true
+
+	GitRepoCollector.Register()
+	ClusterCollector.Register()
+	ClusterGroupCollector.Register()
+	BundleCollector.Register()
+	BundleDeploymentCollector.Register()
+}
+
+// CollectorCollection implements the generic methods `Delete` and `Register`
+// for a collection of Prometheus collectors. It is used to manage the lifecycle
+// of a collection of Prometheus collectors.
+type CollectorCollection struct {
+	subsystem string
+	metrics   map[string]prometheus.Collector
+	collector func(obj any, metrics map[string]prometheus.Collector)
+}
+
+// Collect collects the metrics for the given object. It deletes the metrics for
+// the object if they already exist and then collects the metrics for the
+// object.
+//
+// The metrics need to be deleted because the values of the metrics may have
+// changed and this would create a new instance of those metrics, keeping the
+// old one around. Metrics are deleted by their name and namespace label values.
+func (c *CollectorCollection) Collect(ctx context.Context, obj metav1.ObjectMetaAccessor) {
+	logger := log.FromContext(ctx).WithName("metrics")
+	if !enabled {
+		return
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			msg, ok := r.(string)
+			if !ok {
+				msg = "unexpected error"
+			}
+			logger.V(0).Error(errors.New("error collecting metrics"), msg, r)
+		}
+	}()
+	c.Delete(obj.GetObjectMeta().GetName(), obj.GetObjectMeta().GetNamespace())
+	c.collector(obj, c.metrics)
+}
+
+// Delete deletes the metric with the given name and namespace labels. It
+// returns the number of metrics deleted. It does a DeletePartialMatch on the
+// metric with the given name and namespace labels.
+func (c *CollectorCollection) Delete(name, namespace string) (deleted int) {
+	identityLabels := prometheus.Labels{
+		"name":      name,
+		"namespace": namespace,
+	}
+	for _, collector := range c.metrics {
+		switch metric := collector.(type) {
+		case *prometheus.MetricVec:
+			deleted += metric.DeletePartialMatch(identityLabels)
+		case *prometheus.CounterVec:
+			deleted += metric.DeletePartialMatch(identityLabels)
+		case *prometheus.GaugeVec:
+			deleted += metric.DeletePartialMatch(identityLabels)
+		default:
+			panic("unexpected metric type")
+		}
+	}
+
+	return deleted
+}
+
+func (c *CollectorCollection) Register() {
+	for _, metric := range c.metrics {
+		metrics.Registry.MustRegister(metric)
+	}
+}
diff --git a/internal/mocks/client_mock.go b/internal/mocks/client_mock.go
index 4e353f8e..159222f2 100644
--- a/internal/mocks/client_mock.go
+++ b/internal/mocks/client_mock.go
@@ -3,7 +3,7 @@
 //
 // Generated by this command:
 //
-//	mockgen --build_flags=--mod=mod -destination=../../../mocks/client_mock.go -package=mocks sigs.k8s.io/controller-runtime/pkg/client Client,SubResourceWriter
+//	mockgen --build_flags=--mod=mod -destination=../../../../mocks/client_mock.go -package=mocks sigs.k8s.io/controller-runtime/pkg/client Client,SubResourceWriter
 //
 
 // Package mocks is a generated GoMock package.
diff --git a/internal/mocks/poller_mock.go b/internal/mocks/poller_mock.go
index 7ad617b9..6e88fe09 100644
--- a/internal/mocks/poller_mock.go
+++ b/internal/mocks/poller_mock.go
@@ -1,9 +1,9 @@
 // Code generated by MockGen. DO NOT EDIT.
-// Source: github.com/rancher/fleet/internal/cmd/gitjob (interfaces: GitPoller)
+// Source: github.com/rancher/fleet/internal/cmd/controller/gitops/reconciler (interfaces: GitPoller)
 //
 // Generated by this command:
 //
-//	mockgen --build_flags=--mod=mod -destination=../../mocks/poller_mock.go -package=mocks github.com/rancher/fleet/internal/cmd/gitjob GitPoller
+//	mockgen --build_flags=--mod=mod -destination=../../../../mocks/poller_mock.go -package=mocks github.com/rancher/fleet/internal/cmd/controller/gitops/reconciler GitPoller
 //
 
 // Package mocks is a generated GoMock package.
diff --git a/package/Dockerfile b/package/Dockerfile
index 009a650a..dc0306f7 100644
--- a/package/Dockerfile
+++ b/package/Dockerfile
@@ -3,32 +3,30 @@ ARG ARCH
 
 FROM --platform=linux/$ARCH registry.suse.com/bci/bci-base:15.5 AS base
 COPY package/log.sh /usr/bin/
-RUN zypper -n update && \
-    zypper -n install openssh-clients catatonit git-core && \
-    zypper -n clean -a
+RUN zypper rm -y container-suseconnect && \
+    zypper ar --priority=500 https://download.opensuse.org/repositories/Virtualization:containers/5.5/Virtualization:containers.repo && \
+    zypper --gpg-auto-import-keys ref && \
+    zypper -n update && \
+    zypper -n install --no-recommends openssh-clients tini git-core && \
+    zypper -n clean -a && \
+    rm -fr /var/log/zypp* /usr/share/doc
 
 FROM base AS copy_dapper
 ONBUILD ARG ARCH
 ONBUILD COPY bin/fleetcontroller-linux-$ARCH /usr/bin/fleetcontroller
 ONBUILD COPY bin/fleet-linux-$ARCH /usr/bin/fleet
-ONBUILD COPY bin/gitjob-linux-$ARCH /usr/bin/gitjob
-ONBUILD COPY bin/gitcloner-linux-$ARCH /usr/bin/gitcloner
 
 FROM base AS copy_buildx
 ONBUILD ARG TARGETARCH
 ONBUILD COPY bin/fleetcontroller-linux-$TARGETARCH /usr/bin/fleetcontroller
 ONBUILD COPY bin/fleet-linux-$TARGETARCH /usr/bin/fleet
-ONBUILD COPY bin/gitjob-linux-$TARGETARCH /usr/bin/gitjob
-ONBUILD COPY bin/gitcloner-linux-$TARGETARCH /usr/bin/gitcloner
 
 FROM base AS copy_goreleaser
 ONBUILD ARG ARCH
 ONBUILD COPY fleetcontroller-linux-$ARCH /usr/bin/fleetcontroller
 ONBUILD COPY fleet-linux-$ARCH /usr/bin/fleet
-ONBUILD COPY gitjob-linux-$ARCH /usr/bin/gitjob
-ONBUILD COPY gitcloner-linux-$ARCH /usr/bin/gitcloner
 
 FROM copy_${BUILD_ENV}
 USER 1000
-ENTRYPOINT ["catatonit", "--"]
+ENTRYPOINT ["tini", "--"]
 CMD ["fleetcontroller"]
diff --git a/pkg/sharding/sharding.go b/pkg/sharding/sharding.go
new file mode 100644
index 00000000..e8fd1c46
--- /dev/null
+++ b/pkg/sharding/sharding.go
@@ -0,0 +1,33 @@
+package sharding
+
+import (
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/event"
+	"sigs.k8s.io/controller-runtime/pkg/predicate"
+)
+
+const ShardingLabel string = "fleet.cattle.io/shard"
+
+func FilterByShardID(shardID string) predicate.Funcs {
+	matchesLabel := func(o client.Object) bool {
+		label, hasLabel := o.GetLabels()[ShardingLabel]
+
+		if shardID == "" {
+			return !hasLabel
+		}
+
+		return label == shardID
+	}
+
+	return predicate.Funcs{
+		CreateFunc: func(e event.CreateEvent) bool {
+			return matchesLabel(e.Object)
+		},
+		UpdateFunc: func(e event.UpdateEvent) bool {
+			return matchesLabel(e.ObjectNew)
+		},
+		DeleteFunc: func(e event.DeleteEvent) bool {
+			return matchesLabel(e.Object)
+		},
+	}
+}


Test patch:
diff --git a/.github/workflows/e2e-ci.yml b/.github/workflows/e2e-ci.yml
index 2116ca8d..6f4f661a 100644
--- a/.github/workflows/e2e-ci.yml
+++ b/.github/workflows/e2e-ci.yml
@@ -82,7 +82,7 @@ jobs:
       -
         name: Deploy Fleet
         run: |
-          ./.github/scripts/deploy-fleet.sh
+          SHARDS="shard1,shard2,shard3" ./.github/scripts/deploy-fleet.sh
       -
         name: Create Zot certificates for OCI tests
         env:
@@ -106,21 +106,24 @@ jobs:
           # 1. Run test cases not needing infra
           ginkgo --label-filter='!infra-setup' e2e/single-cluster e2e/keep-resources
 
-          # 2. Run tests requiring only the git server
+          # 2. Run tests for metrics
+          ginkgo e2e/metrics
+
+          # 3. Run tests requiring only the git server
           e2e/testenv/infra/infra setup --git-server=true
           ginkgo --label-filter='infra-setup && !helm-registry && !oci-registry' e2e/single-cluster/
 
-          # 3. Run tests requiring a Helm registry
+          # 4. Run tests requiring a Helm registry
           e2e/testenv/infra/infra setup --helm-registry=true
           ginkgo --label-filter='helm-registry' e2e/single-cluster
 
           e2e/testenv/infra/infra teardown --helm-registry=true
 
-          # 4. Run tests requiring an OCI registry
+          # 5. Run tests requiring an OCI registry
           e2e/testenv/infra/infra setup --oci-registry=true
           ginkgo --label-filter='oci-registry' e2e/single-cluster
 
-          # 5. Tear down all infra
+          # 6. Tear down all infra
           e2e/testenv/infra/infra teardown
 
       -
diff --git a/e2e/assets/clustergroup-template.yaml b/e2e/assets/clustergroup-template.yaml
new file mode 100644
index 00000000..7c8f3194
--- /dev/null
+++ b/e2e/assets/clustergroup-template.yaml
@@ -0,0 +1,11 @@
+apiVersion: fleet.cattle.io/v1alpha1
+kind: ClusterGroup
+metadata:
+  name: {{ .Name }}
+  namespace: {{ .Namespace }}
+spec:
+  selector:
+    matchLabels:
+      {{- range $key, $value := .MatchLabels}}
+      {{$key}}: {{$value}}
+      {{- end}}
diff --git a/e2e/assets/gitrepo/gitrepo_sharded.yaml b/e2e/assets/gitrepo/gitrepo_sharded.yaml
new file mode 100644
index 00000000..369ec3b0
--- /dev/null
+++ b/e2e/assets/gitrepo/gitrepo_sharded.yaml
@@ -0,0 +1,13 @@
+kind: GitRepo
+apiVersion: fleet.cattle.io/v1alpha1
+metadata:
+  name: {{.Name}}
+  labels:
+    fleet.cattle.io/shard: {{.ShardID}}
+spec:
+  repo: {{.Repo}}
+  branch: {{.Branch}}
+  pollingInterval: {{.PollingInterval}}
+  targetNamespace: {{.TargetNamespace}}
+  paths:
+  - examples
diff --git a/e2e/assets/metrics/fleetcontroller_service.yaml b/e2e/assets/metrics/fleetcontroller_service.yaml
new file mode 100644
index 00000000..dea6ffee
--- /dev/null
+++ b/e2e/assets/metrics/fleetcontroller_service.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ .Name }}
+spec:
+  selector:
+    app: fleet-controller
+    shard-default: "true"
+  ports:
+    - protocol: TCP
+      port: {{ .Port }}
+      targetPort: metrics
+  type: LoadBalancer
diff --git a/e2e/metrics/bundle_test.go b/e2e/metrics/bundle_test.go
new file mode 100644
index 00000000..91a836b0
--- /dev/null
+++ b/e2e/metrics/bundle_test.go
@@ -0,0 +1,181 @@
+package metrics_test
+
+import (
+	"fmt"
+	"maps"
+	"math/rand"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+
+	"github.com/rancher/fleet/e2e/metrics"
+	"github.com/rancher/fleet/e2e/testenv"
+	"github.com/rancher/fleet/e2e/testenv/kubectl"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+)
+
+var _ = Describe("Bundle Metrics", Label("bundle"), func() {
+	const (
+		gitRepoName = "metrics"
+		branch      = "master"
+	)
+
+	var (
+		// kw is the kubectl command for namespace the workload is deployed to
+		kw        kubectl.Command
+		namespace string
+	)
+
+	BeforeEach(func() {
+		k = env.Kubectl.Namespace(env.Namespace)
+		namespace = testenv.NewNamespaceName(
+			gitRepoName,
+			rand.New(rand.NewSource(time.Now().UnixNano())),
+		)
+		kw = k.Namespace(namespace)
+
+		out, err := k.Create("ns", namespace)
+		Expect(err).ToNot(HaveOccurred(), out)
+
+		err = testenv.CreateGitRepo(
+			kw,
+			namespace,
+			gitRepoName,
+			branch,
+			"simple-manifest",
+		)
+		Expect(err).ToNot(HaveOccurred())
+
+		DeferCleanup(func() {
+			out, err = k.Delete("ns", namespace)
+			Expect(err).ToNot(HaveOccurred(), out)
+		})
+	})
+
+	When("collecting Bundle metrics", func() {
+		bundleMetricNames := map[string]map[string][]string{
+			"fleet_bundle_desired_ready": {},
+			"fleet_bundle_err_applied":   {},
+			"fleet_bundle_modified":      {},
+			"fleet_bundle_not_ready":     {},
+			"fleet_bundle_out_of_sync":   {},
+			"fleet_bundle_pending":       {},
+			"fleet_bundle_ready":         {},
+			"fleet_bundle_wait_applied":  {},
+			"fleet_bundle_state": {
+				"state": []string{
+					string(fleet.Ready),
+					string(fleet.NotReady),
+					string(fleet.WaitApplied),
+					string(fleet.ErrApplied),
+					string(fleet.OutOfSync),
+					string(fleet.Pending),
+					string(fleet.Modified),
+				},
+			},
+		}
+
+		// checkMetrics checks that the metrics exist or not exist. Custom
+		// checks can be added by passing a function to check. This can be used
+		// to check for the value of the metrics.
+		checkMetrics := func(
+			gitRepoName string,
+			expectExists bool,
+			check func(metric *metrics.Metric) error,
+		) func() error {
+			return func() error {
+				et := metrics.NewExporterTest(metricsURL)
+				expectOne := func(metricName string, labels map[string]string) error {
+					metric, err := et.FindOneMetric(metricName, labels)
+					if expectExists && err != nil {
+						return err
+					} else if !expectExists && err == nil {
+						return fmt.Errorf("metric %s found but not expected", metricName)
+					}
+
+					if check != nil {
+						err = check(metric)
+						if err != nil {
+							return err
+						}
+					}
+					return nil
+				}
+
+				for metricName, matchLabels := range bundleMetricNames {
+					identityLabels := map[string]string{
+						"name":      gitRepoName,
+						"namespace": namespace,
+					}
+					labels := map[string]string{}
+					maps.Copy(labels, identityLabels)
+
+					if len(matchLabels) > 0 {
+						for labelName, labelValues := range matchLabels {
+							for _, labelValue := range labelValues {
+								labels[labelName] = labelValue
+								err := expectOne(metricName, labels)
+								if err != nil {
+									return err
+								}
+							}
+						}
+					} else {
+						err := expectOne(metricName, labels)
+						if err != nil {
+							return err
+						}
+					}
+				}
+				return nil
+			}
+		}
+
+		It("should have one metric for each specified metric and label value", func() {
+			Eventually(checkMetrics(gitRepoName+"-simple-manifest", true, func(metric *metrics.Metric) error {
+				// No cluster exists in the namespace where our GitRepo has been deployed, hence
+				// we expect the values of the metrics to be 0.
+				if value := metric.Gauge.GetValue(); value != float64(0) {
+					return fmt.Errorf("unexpected metric value: expected 0, found %f", value)
+				}
+				return nil
+			})).ShouldNot(HaveOccurred())
+		})
+
+		When("the GitRepo (and therefore Bundle) is changed", Label("bundle-modified"), func() {
+			It("should not duplicate metrics if Bundle is updated", Label("bundle-update"), func() {
+				// et := metrics.NewExporterTest(metricsURL)
+				out, err := kw.Patch(
+					"gitrepo", gitRepoName,
+					"--type=json",
+					"-p", `[{"op": "replace", "path": "/spec/paths", "value": ["simple-chart"]}]`,
+				)
+				Expect(err).ToNot(HaveOccurred(), out)
+				Expect(out).To(ContainSubstring("gitrepo.fleet.cattle.io/metrics patched"))
+
+				Eventually(checkMetrics(gitRepoName+"-simple-chart", true, func(metric *metrics.Metric) error {
+					if metric.LabelValue("paths") == "simple-manifest" {
+						return fmt.Errorf("path for metric %s unchanged", metric.Metric.String())
+					}
+					return nil
+				})).ShouldNot(HaveOccurred())
+			})
+
+			It("should not keep metrics if Bundle is deleted", Label("bundle-delete"), func() {
+				gitRepoName := gitRepoName + "-simple-manifest"
+
+				var (
+					out string
+					err error
+				)
+				Eventually(func() error {
+					out, err = kw.Delete("bundle", gitRepoName)
+					return err
+				}).ShouldNot(HaveOccurred(), out)
+
+				Eventually(checkMetrics(gitRepoName, false, nil)).ShouldNot(HaveOccurred())
+			})
+		})
+	})
+})
diff --git a/e2e/metrics/bundledeployment_test.go b/e2e/metrics/bundledeployment_test.go
new file mode 100644
index 00000000..875088c3
--- /dev/null
+++ b/e2e/metrics/bundledeployment_test.go
@@ -0,0 +1,168 @@
+package metrics_test
+
+import (
+	"fmt"
+	"math/rand"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+
+	"github.com/rancher/fleet/e2e/metrics"
+	"github.com/rancher/fleet/e2e/testenv"
+	"github.com/rancher/fleet/e2e/testenv/kubectl"
+)
+
+var _ = Describe("BundleDeployment Metrics", Label("bundledeployment"), func() {
+	const (
+		branch    = "master"
+		namespace = "fleet-local" // required for this test to create BundleDeployments
+	)
+
+	var (
+		// kw is the kubectl command for namespace the workload is deployed to
+		kw kubectl.Command
+		// objName is going to be "randomized" instead of using a dedicated and
+		// random namespace, like it is the case for the other tests.
+		objName string
+	)
+
+	BeforeEach(func() {
+		k = env.Kubectl.Namespace(env.Namespace)
+		kw = k.Namespace(namespace)
+		objName = testenv.AddRandomSuffix(
+			"metrics",
+			rand.New(rand.NewSource(time.Now().UnixNano())),
+		)
+
+		err := testenv.CreateGitRepo(
+			kw,
+			namespace,
+			objName,
+			branch,
+			"simple-manifest",
+		)
+		Expect(err).ToNot(HaveOccurred())
+
+		DeferCleanup(func() {
+			out, err := k.Delete("gitrepo", objName)
+			Expect(err).ToNot(HaveOccurred(), out)
+		})
+	})
+
+	When("testing BundleDeployment metrics", func() {
+		bundleDeploymentMetricNames := []string{
+			"fleet_bundledeployment_state",
+		}
+		bundleDeploymentMetricStates := []string{
+			"ErrApplied",
+			"Modified",
+			"NotReady",
+			"OutOfSync",
+			"Pending",
+			"Ready",
+			"WaitApplied",
+		}
+
+		It("should have exactly one metric for the BundleDeployment", func() {
+			et := metrics.NewExporterTest(metricsURL)
+			Eventually(func() error {
+				for _, metricName := range bundleDeploymentMetricNames {
+					for _, state := range bundleDeploymentMetricStates {
+						_, err := et.FindOneMetric(
+							metricName,
+							map[string]string{
+								"name":              objName + "-simple-manifest",
+								"cluster_namespace": namespace,
+								"state":             state,
+							},
+						)
+						if err != nil {
+							return err
+						}
+					}
+				}
+				return nil
+			}).ShouldNot(HaveOccurred())
+		})
+
+		Context(
+			"when the GitRepo (and therefore Bundle) is changed",
+			Label("bundle-altered"),
+			func() {
+				It(
+					"it should not duplicate metrics if Bundle is updated",
+					Label("bundle-update"),
+					func() {
+						et := metrics.NewExporterTest(metricsURL)
+						out, err := kw.Patch(
+							"gitrepo", objName,
+							"--type=json",
+							"-p",
+							`[{"op": "replace", "path": "/spec/paths", "value": ["simple-chart"]}]`,
+						)
+						Expect(err).ToNot(HaveOccurred(), out)
+						Expect(out).To(ContainSubstring(
+							fmt.Sprintf("gitrepo.fleet.cattle.io/%s patched", objName)))
+
+						// Wait for it to be changed and fetched.
+						Eventually(func() (string, error) {
+							return kw.Get("gitrepo", objName, "-o", "jsonpath={.status.commit}")
+						}).ShouldNot(BeEmpty())
+
+						// Expect still no metrics to be duplicated.
+						Eventually(func() error {
+							for _, metricName := range bundleDeploymentMetricNames {
+								for _, metricState := range bundleDeploymentMetricStates {
+									_, err = et.FindOneMetric(
+										metricName,
+										map[string]string{
+											"name":              objName + "-simple-chart",
+											"cluster_namespace": namespace,
+											"state":             metricState,
+										},
+									)
+									if err != nil {
+										return err
+									}
+								}
+							}
+							return nil
+						}).ShouldNot(HaveOccurred())
+					})
+
+				It("should not keep metrics if Bundle is deleted", Label("bundle-delete"), func() {
+					et := metrics.NewExporterTest(metricsURL)
+
+					objName := objName + "-simple-manifest"
+
+					Eventually(func() (string, error) {
+						return kw.Get("-A", "bundledeployment")
+					}).Should(ContainSubstring(objName))
+
+					var (
+						out string
+						err error
+					)
+					out, err = kw.Delete("bundle", objName)
+					Expect(err).ToNot(HaveOccurred(), out)
+
+					Eventually(func() error {
+						for _, metricName := range bundleDeploymentMetricNames {
+							_, err := et.FindOneMetric(
+								metricName,
+								map[string]string{
+									"name":      objName,
+									"namespace": namespace,
+								},
+							)
+							if err == nil {
+								return fmt.Errorf("metric %s found but not expected", metricName)
+							}
+						}
+						return nil
+					}).ShouldNot(HaveOccurred())
+				})
+			})
+	})
+})
diff --git a/e2e/metrics/cluster_test.go b/e2e/metrics/cluster_test.go
new file mode 100644
index 00000000..5d99cc98
--- /dev/null
+++ b/e2e/metrics/cluster_test.go
@@ -0,0 +1,108 @@
+package metrics_test
+
+import (
+	"encoding/json"
+	"fmt"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/rancher/fleet/e2e/metrics"
+)
+
+type cluster struct {
+	Name      string `json:"name"`
+	Namespace string `json:"namespace"`
+}
+
+type clusters []cluster
+
+func (cs *clusters) UnmarshalJSON(data []byte) error {
+	assertMap := func(i interface{}) map[string]interface{} {
+		return i.(map[string]interface{})
+	}
+
+	var tmp interface{}
+	err := json.Unmarshal(data, &tmp)
+	if err != nil {
+		return err
+	}
+
+	m := tmp.(map[string]interface{})
+	items := m["items"].([]interface{})
+
+	*cs = clusters{}
+
+	for _, item := range items {
+		metadata := assertMap(assertMap(item)["metadata"])
+
+		c := cluster{}
+		c.Namespace = metadata["namespace"].(string)
+		c.Name = metadata["name"].(string)
+
+		*cs = append(*cs, c)
+	}
+
+	return nil
+}
+
+var _ = Describe("Cluster Metrics", Label("cluster"), func() {
+	expectedMetricsExist := map[string]bool{
+		"fleet_cluster_desired_ready_git_repos":      true,
+		"fleet_cluster_ready_git_repos":              true,
+		"fleet_cluster_resources_count_desiredready": true,
+		"fleet_cluster_resources_count_missing":      true,
+		"fleet_cluster_resources_count_modified":     true,
+		"fleet_cluster_resources_count_notready":     true,
+		"fleet_cluster_resources_count_orphaned":     true,
+		"fleet_cluster_resources_count_ready":        true,
+		"fleet_cluster_resources_count_unknown":      true,
+		"fleet_cluster_resources_count_waitapplied":  true,
+		// The value of cluster.Status.Display.State is empty if no issues are
+		// found and this means no metric is created.
+		"fleet_cluster_state": false,
+	}
+
+	It("should have metrics for all existing cluster resources", func() {
+		Eventually(func() error {
+			var (
+				clustersOut string
+				err         error
+			)
+			clustersOut, err = env.Kubectl.Get(
+				"-A", "clusters.fleet.cattle.io",
+				"-o", "json",
+			)
+			Expect(err).ToNot(HaveOccurred())
+
+			var existingClusters clusters
+			err = json.Unmarshal([]byte(clustersOut), &existingClusters)
+			Expect(err).ToNot(HaveOccurred())
+
+			et := metrics.NewExporterTest(metricsURL)
+
+			Expect(len(existingClusters)).ToNot(BeZero())
+
+			for _, cluster := range existingClusters {
+				for metricName, expectedExist := range expectedMetricsExist {
+					_, err := et.FindOneMetric(
+						metricName,
+						map[string]string{
+							"name":      cluster.Name,
+							"namespace": cluster.Namespace,
+						},
+					)
+					if expectedExist && err != nil {
+						return err
+					} else if !expectedExist && err == nil {
+						return fmt.Errorf(
+							"expected metric %s not to exist, but it exists",
+							metricName,
+						)
+					}
+				}
+			}
+			return nil
+		}).ShouldNot(HaveOccurred())
+	},
+	)
+})
diff --git a/e2e/metrics/clustergroup_test.go b/e2e/metrics/clustergroup_test.go
new file mode 100644
index 00000000..cf0f54e7
--- /dev/null
+++ b/e2e/metrics/clustergroup_test.go
@@ -0,0 +1,116 @@
+package metrics_test
+
+import (
+	"maps"
+	"math/rand"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/rancher/fleet/e2e/metrics"
+	"github.com/rancher/fleet/e2e/testenv"
+)
+
+var _ = Describe("Cluster Metrics", Label("clustergroup"), func() {
+	const (
+		namespace = "fleet-local"
+	)
+
+	var (
+		clusterGroupName string
+	)
+
+	expectedMetricsExist := map[string]map[string][]string{
+		"fleet_cluster_group_bundle_desired_ready":         {},
+		"fleet_cluster_group_bundle_ready":                 {},
+		"fleet_cluster_group_cluster_count":                {},
+		"fleet_cluster_group_non_ready_cluster_count":      {},
+		"fleet_cluster_group_resource_count_desired_ready": {},
+		"fleet_cluster_group_resource_count_missing":       {},
+		"fleet_cluster_group_resource_count_modified":      {},
+		"fleet_cluster_group_resource_count_notready":      {},
+		"fleet_cluster_group_resource_count_orphaned":      {},
+		"fleet_cluster_group_resource_count_ready":         {},
+		"fleet_cluster_group_resource_count_unknown":       {},
+		"fleet_cluster_group_resource_count_waitapplied":   {},
+		"fleet_cluster_group_state": {
+			"state": {
+				"Ready",
+				"NotReady",
+			},
+		},
+	}
+
+	BeforeEach(func() {
+		clusterGroupName = testenv.AddRandomSuffix(
+			"test-cluster-group",
+			rand.NewSource(time.Now().UnixNano()),
+		)
+		err := testenv.CreateClusterGroup(
+			k,
+			namespace,
+			clusterGroupName,
+			map[string]string{
+				"name": "local",
+			},
+		)
+		Expect(err).ToNot(HaveOccurred())
+
+		DeferCleanup(func() {
+			out, err := k.Delete(
+				"clustergroups.fleet.cattle.io",
+				clusterGroupName,
+				"-n", namespace,
+			)
+			Expect(out).To(ContainSubstring("deleted"))
+			Expect(err).ToNot(HaveOccurred())
+		})
+	})
+
+	// The cluster group is created without an UID. This UID is added shortly
+	// after the creation of the cluster group. This results in the cluster
+	// group being modified and, if not properly checked, duplicated metrics.
+	// This is why this test does test for duplicated metrics as well, although
+	// it does not look like it.
+	It("should have all metrics for a single cluster group once", func() {
+		Eventually(func() (string, error) {
+			return env.Kubectl.Get(
+				"-n", namespace,
+				"clustergroups.fleet.cattle.io",
+				clusterGroupName,
+				"-o", "jsonpath=.metadata.name",
+			)
+		}).ShouldNot(ContainSubstring("not found"))
+
+		et := metrics.NewExporterTest(metricsURL)
+
+		Eventually(func() error {
+			identityLabels := map[string]string{
+				"name":      clusterGroupName,
+				"namespace": namespace,
+			}
+
+			for metricName, expectedLabels := range expectedMetricsExist {
+				if len(expectedLabels) == 0 {
+					_, err := et.FindOneMetric(metricName, identityLabels)
+					if err != nil {
+						return err
+					}
+					Expect(err).ToNot(HaveOccurred())
+				} else {
+					for labelName, labelValues := range expectedLabels {
+						for _, labelValue := range labelValues {
+							labels := maps.Clone(identityLabels)
+							labels[labelName] = labelValue
+							_, err := et.FindOneMetric(metricName, labels)
+							if err != nil {
+								return err
+							}
+						}
+					}
+				}
+			}
+			return nil
+		}).ShouldNot(HaveOccurred())
+	})
+})
diff --git a/e2e/metrics/exporter.go b/e2e/metrics/exporter.go
new file mode 100644
index 00000000..76f2dfd8
--- /dev/null
+++ b/e2e/metrics/exporter.go
@@ -0,0 +1,145 @@
+package metrics
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	dto "github.com/prometheus/client_model/go"
+	"github.com/prometheus/common/expfmt"
+)
+
+type ExporterTest struct {
+	url string
+}
+
+func NewExporterTest(url string) *ExporterTest {
+	return &ExporterTest{
+		url: url,
+	}
+}
+
+// getMetrics fetches the metrics from the Prometheus endpoint and returns them
+// as a map of metric families.
+func (et *ExporterTest) getMetrics() (map[string]*dto.MetricFamily, error) {
+	resp, err := http.Get(et.url)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+	}
+
+	var parser expfmt.TextParser
+	metrics, err := parser.TextToMetricFamilies(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+	return metrics, nil
+}
+
+// FindOneMetric expects to find exactly one metric with the given name, resource name,
+// resource namespace, and labels. If no such metric is found, or if more than
+// one is found, an error is returned.
+//
+// `resourceName` and `resourceNamespace` are the values of the `name` and
+// `namespace` labels, respectively.
+//
+// If labels is nil, only the name and namespace labels are checked.
+func (m *ExporterTest) FindOneMetric(
+	metricName string,
+	labels map[string]string,
+) (*Metric, error) {
+	allMetrics, err := m.getMetrics()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get metrics: %w", err)
+	}
+
+	// Metric name exists.
+	mf, ok := allMetrics[metricName]
+	if !ok {
+		return nil, fmt.Errorf("metric %q not found", metricName)
+	}
+
+	var metrics []*dto.Metric
+	for _, metric := range mf.Metric {
+		m := Metric{Metric: metric}
+
+		// Check that all labels match, if present.
+		match := true
+		for k, v := range labels {
+			if m.LabelValue(k) != v {
+				match = false
+				break
+			}
+		}
+		if match {
+			metrics = append(metrics, metric)
+		}
+	}
+
+	if len(metrics) != 1 {
+		return nil, fmt.Errorf(
+			"expected to find 1 metric for %s{%s}, got %d",
+			metricName,
+			promLabels(labels),
+			len(metrics),
+		)
+	}
+
+	return &Metric{Metric: metrics[0]}, nil
+}
+
+type promLabels map[string]string
+
+func (l promLabels) String() string {
+	labels := make([]string, 0, len(l))
+	for k, v := range l {
+		labels = append(labels, fmt.Sprintf("%s=%q", k, v))
+	}
+	return strings.Join(labels, ",")
+}
+
+type Metric struct {
+	*dto.Metric
+}
+
+// LabelValue returns the value of the label with the given name. If no such
+// label is found, an empty string is returned.
+func (m *Metric) LabelValue(name string) string {
+	for _, label := range m.Label {
+		if *label.Name == name {
+			return *label.Value
+		}
+	}
+	return ""
+}
+
+func (m *Metric) MatchLabelValue(name, value string) error {
+	for _, label := range m.Label {
+		if *label.Name == name {
+			if *label.Value == value {
+				return nil
+			} else {
+				return fmt.Errorf(
+					"expected label %q to have value %q, got %q",
+					name,
+					value,
+					*label.Value,
+				)
+			}
+		}
+	}
+	return fmt.Errorf("label %q not found", name)
+}
+
+func (m *Metric) HasLabel(name string) bool {
+	for _, label := range m.Label {
+		if *label.Name == name {
+			return true
+		}
+	}
+	return false
+}
diff --git a/e2e/metrics/gitrepo_test.go b/e2e/metrics/gitrepo_test.go
new file mode 100644
index 00000000..cd92079a
--- /dev/null
+++ b/e2e/metrics/gitrepo_test.go
@@ -0,0 +1,150 @@
+package metrics_test
+
+import (
+	"fmt"
+	"math/rand"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+
+	"github.com/rancher/fleet/e2e/metrics"
+	"github.com/rancher/fleet/e2e/testenv"
+	"github.com/rancher/fleet/e2e/testenv/kubectl"
+)
+
+var _ = Describe("GitRepo Metrics", Label("gitrepo"), func() {
+	const (
+		objName = "metrics"
+		branch  = "master"
+	)
+
+	var (
+		// kw is the kubectl command for namespace the workload is deployed to
+		kw        kubectl.Command
+		namespace string
+	)
+
+	BeforeEach(func() {
+		k = env.Kubectl.Namespace(env.Namespace)
+		namespace = testenv.NewNamespaceName(
+			objName,
+			rand.New(rand.NewSource(time.Now().UnixNano())),
+		)
+		kw = k.Namespace(namespace)
+
+		out, err := k.Create("ns", namespace)
+		Expect(err).ToNot(HaveOccurred(), out)
+
+		err = testenv.CreateGitRepo(
+			kw,
+			namespace,
+			objName,
+			branch,
+			"simple-manifest",
+		)
+		Expect(err).ToNot(HaveOccurred())
+
+		DeferCleanup(func() {
+			out, err = k.Delete("ns", namespace)
+			Expect(err).ToNot(HaveOccurred(), out)
+		})
+	})
+
+	When("testing GitRepo metrics", func() {
+		gitrepoMetricNames := []string{
+			"fleet_gitrepo_desired_ready_clusters",
+			"fleet_gitrepo_ready_clusters",
+			"fleet_gitrepo_resources_desired_ready",
+			"fleet_gitrepo_resources_missing",
+			"fleet_gitrepo_resources_modified",
+			"fleet_gitrepo_resources_not_ready",
+			"fleet_gitrepo_resources_orphaned",
+			"fleet_gitrepo_resources_ready",
+			"fleet_gitrepo_resources_unknown",
+			"fleet_gitrepo_resources_wait_applied",
+		}
+
+		It("should have exactly one metric of each type for the gitrepo", func() {
+			et := metrics.NewExporterTest(metricsURL)
+			Eventually(func() error {
+				for _, metricName := range gitrepoMetricNames {
+					metric, err := et.FindOneMetric(
+						metricName,
+						map[string]string{
+							"name":      objName,
+							"namespace": namespace,
+						},
+					)
+					if err != nil {
+						return err
+					}
+					Expect(metric.Gauge.GetValue()).To(Equal(float64(0)))
+				}
+				return nil
+			}).ShouldNot(HaveOccurred())
+		})
+
+		Context("when the GitRepo is changed", func() {
+			It("it should not duplicate metrics if GitRepo is updated", func() {
+				et := metrics.NewExporterTest(metricsURL)
+				out, err := kw.Patch(
+					"gitrepo", objName,
+					"--type=json",
+					"-p", `[{"op": "replace", "path": "/spec/paths", "value": ["simple-chart"]}]`,
+				)
+				Expect(err).ToNot(HaveOccurred(), out)
+				Expect(out).To(ContainSubstring("gitrepo.fleet.cattle.io/metrics patched"))
+
+				// Wait for it to be changed and fetched.
+				Eventually(func() (string, error) {
+					return kw.Get("gitrepo", objName, "-o", "jsonpath={.status.commit}")
+				}).ShouldNot(BeEmpty())
+
+				var metric *metrics.Metric
+				// Expect still no metrics to be duplicated.
+				Eventually(func() error {
+					for _, metricName := range gitrepoMetricNames {
+						metric, err = et.FindOneMetric(
+							metricName,
+							map[string]string{
+								"name":      objName,
+								"namespace": namespace,
+							},
+						)
+						if err != nil {
+							return err
+						}
+						if metric.LabelValue("paths") != "simple-chart" {
+							return fmt.Errorf("path for metric %s unchanged", metricName)
+						}
+					}
+					return nil
+				}).ShouldNot(HaveOccurred())
+			})
+
+			It("should not keep metrics if GitRepo is deleted", Label("gitrepo-delete"), func() {
+				et := metrics.NewExporterTest(metricsURL)
+
+				out, err := kw.Delete("gitrepo", objName)
+				Expect(err).ToNot(HaveOccurred(), out)
+
+				Eventually(func() error {
+					for _, metricName := range gitrepoMetricNames {
+						_, err := et.FindOneMetric(
+							metricName,
+							map[string]string{
+								"name":      objName,
+								"namespace": namespace,
+							},
+						)
+						if err == nil {
+							return fmt.Errorf("metric %s found", metricName)
+						}
+					}
+					return nil
+				}).ShouldNot(HaveOccurred())
+			})
+		})
+	})
+})
diff --git a/e2e/metrics/suite_test.go b/e2e/metrics/suite_test.go
new file mode 100644
index 00000000..30e65c45
--- /dev/null
+++ b/e2e/metrics/suite_test.go
@@ -0,0 +1,78 @@
+package metrics_test
+
+import (
+	"fmt"
+	"math/rand"
+	"os"
+	"testing"
+	"time"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+
+	"github.com/rancher/fleet/e2e/testenv"
+	"github.com/rancher/fleet/e2e/testenv/kubectl"
+)
+
+func TestE2E(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "E2E Suite for metrics")
+}
+
+var (
+	env *testenv.Env
+	// k is the kubectl command for the cluster registration namespace
+	k                kubectl.Command
+	metricsURL       string
+	loadBalancerName string
+)
+
+func setupLoadBalancer() {
+	if os.Getenv("METRICS_URL") != "" {
+		metricsURL = os.Getenv("METRICS_URL")
+	} else {
+		rs := rand.NewSource(time.Now().UnixNano())
+		port := rs.Int63()%1000 + 30000
+		loadBalancerName = testenv.AddRandomSuffix("fleetcontroller", rs)
+
+		ks := k.Namespace("cattle-fleet-system")
+		err := testenv.ApplyTemplate(
+			ks,
+			testenv.AssetPath("metrics/fleetcontroller_service.yaml"),
+			map[string]interface{}{
+				"Name": loadBalancerName,
+				"Port": port,
+			},
+		)
+		Expect(err).ToNot(HaveOccurred())
+
+		Eventually(func() (string, error) {
+			ip, err := ks.Get(
+				"service", loadBalancerName,
+				"-o", "jsonpath={.status.loadBalancer.ingress[0].ip}",
+			)
+			metricsURL = fmt.Sprintf("http://%s:%d/metrics", ip, port)
+			return ip, err
+		}).ShouldNot(BeEmpty())
+	}
+}
+
+func tearDownLoadBalancer() {
+	ks := k.Namespace("cattle-fleet-system")
+	out, err := ks.Delete("service", loadBalancerName)
+	Expect(err).ToNot(HaveOccurred(), out)
+}
+
+var _ = BeforeSuite(func() {
+	SetDefaultEventuallyTimeout(time.Minute)
+	SetDefaultEventuallyPollingInterval(time.Second)
+	testenv.SetRoot("../..")
+
+	setupLoadBalancer()
+
+	env = testenv.New()
+})
+
+var _ = AfterSuite(func() {
+	tearDownLoadBalancer()
+})
diff --git a/e2e/single-cluster/imagescan_test.go b/e2e/single-cluster/imagescan_test.go
index b1ce2f6c..359b0703 100644
--- a/e2e/single-cluster/imagescan_test.go
+++ b/e2e/single-cluster/imagescan_test.go
@@ -189,7 +189,7 @@ var _ = Describe("Image Scan dynamic tests pushing to ttl.sh", Label("infra-setu
 })
 
 func getFleetControllerRestarts(k kubectl.Command, index int) int {
-	out, err := k.Namespace("cattle-fleet-system").Get("pods", "-l", "app=fleet-controller",
+	out, err := k.Namespace("cattle-fleet-system").Get("pods", "-l", "app=fleet-controller", "-l", "shard=",
 		"--no-headers",
 		"-o", fmt.Sprintf("custom-columns=RESTARTS:.status.containerStatuses[%d].restartCount", index))
 	Expect(err).NotTo(HaveOccurred())
@@ -200,7 +200,7 @@ func getFleetControllerRestarts(k kubectl.Command, index int) int {
 }
 
 func getFleetControllerReady(k kubectl.Command, index int) bool {
-	out, err := k.Namespace("cattle-fleet-system").Get("pods", "-l", "app=fleet-controller",
+	out, err := k.Namespace("cattle-fleet-system").Get("pods", "-l", "app=fleet-controller", "-l", "shard=",
 		"--no-headers",
 		"-o", fmt.Sprintf("custom-columns=RESTARTS:.status.containerStatuses[%d].ready", index))
 	Expect(err).NotTo(HaveOccurred())
@@ -214,7 +214,7 @@ func getFleetControllerContainerIndexInPod(k kubectl.Command, container string)
 	// the fleet controller pod runs 3 containers.
 	// we need to know the index of the fleet-controller container inside the pod.
 	// get all the container names, and return the index of the given container name
-	out, err := k.Namespace("cattle-fleet-system").Get("pods", "-l", "app=fleet-controller",
+	out, err := k.Namespace("cattle-fleet-system").Get("pods", "-l", "app=fleet-controller", "-l", "shard=",
 		"--no-headers", "-o", "custom-columns=RESTARTS:.status.containerStatuses[*].name")
 	Expect(err).NotTo(HaveOccurred())
 	out = strings.TrimSuffix(out, "\n")
diff --git a/e2e/single-cluster/sharding_test.go b/e2e/single-cluster/sharding_test.go
new file mode 100644
index 00000000..d900de51
--- /dev/null
+++ b/e2e/single-cluster/sharding_test.go
@@ -0,0 +1,206 @@
+package singlecluster_test
+
+import (
+	"fmt"
+	"math/rand"
+	"os"
+	"path"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/onsi/gomega/matchers"
+	"github.com/rancher/fleet/e2e/testenv"
+	"github.com/rancher/fleet/e2e/testenv/githelper"
+	"github.com/rancher/fleet/e2e/testenv/kubectl"
+)
+
+var shards = []string{"shard1", "shard2", "shard3"}
+
+var _ = Describe("Filtering events by shard", Label("sharding", "infra-setup"), Ordered, func() {
+	var (
+		tmpDir           string
+		clonedir         string
+		k                kubectl.Command
+		gh               *githelper.Git
+		inClusterRepoURL string
+		gitrepoName      string
+		r                = rand.New(rand.NewSource(GinkgoRandomSeed()))
+		targetNamespace  string
+	)
+
+	BeforeAll(func() {
+		// No sharded controller should have reconciled any GitRepo until this point.
+		for _, shard := range shards {
+			logs, err := k.Namespace("cattle-fleet-system").Logs(
+				"-l",
+				"app=fleet-controller",
+				"-l",
+				fmt.Sprintf("shard=%s", shard),
+				"--tail=-1",
+			)
+			Expect(err).ToNot(HaveOccurred())
+			regexMatcher := matchers.MatchRegexpMatcher{Regexp: "Reconciling GitRepo.*"}
+			hasReconciledGitRepos, err := regexMatcher.Match(logs)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(hasReconciledGitRepos).To(BeFalse())
+		}
+
+		// Build git repo URL reachable _within_ the cluster, for the GitRepo
+		host, err := githelper.BuildGitHostname(env.Namespace)
+		Expect(err).ToNot(HaveOccurred())
+
+		addr, err := githelper.GetExternalRepoAddr(env, port, repoName)
+		Expect(err).ToNot(HaveOccurred())
+		gh = githelper.NewHTTP(addr)
+
+		inClusterRepoURL = gh.GetInClusterURL(host, port, repoName)
+
+		tmpDir, _ = os.MkdirTemp("", "fleet-")
+		clonedir = path.Join(tmpDir, repoName)
+
+		_, err = gh.Create(clonedir, testenv.AssetPath("gitrepo/sleeper-chart"), "examples")
+		Expect(err).ToNot(HaveOccurred())
+
+		k = env.Kubectl.Namespace(env.Namespace)
+	})
+
+	for _, shard := range shards {
+		When(fmt.Sprintf("deploying a gitrepo labeled with shard ID %s", shard), func() {
+			JustBeforeEach(func() {
+				targetNamespace = testenv.NewNamespaceName("target", r)
+				gitrepoName = testenv.RandomFilename("sharding-test", r)
+
+				err := testenv.ApplyTemplate(
+					k,
+					testenv.AssetPath("gitrepo/gitrepo_sharded.yaml"),
+					struct {
+						Name            string
+						Repo            string
+						Branch          string
+						PollingInterval string
+						TargetNamespace string
+						ShardID         string
+					}{
+						gitrepoName,
+						inClusterRepoURL,
+						gh.Branch,
+						"15s",           // default
+						targetNamespace, // to avoid conflicts with other tests
+						shard,
+					},
+				)
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It(fmt.Sprintf("deploys the gitrepo via the controller labeled with shard ID %s", shard), func() {
+				By("checking the pod exists")
+				Eventually(func() string {
+					out, _ := k.Namespace(targetNamespace).Get("pods")
+					return out
+				}).Should(ContainSubstring("sleeper-"))
+
+				for _, s := range shards {
+					Eventually(func(g Gomega) {
+						logs, err := k.Namespace("cattle-fleet-system").Logs(
+							"-l",
+							"app=fleet-controller",
+							"-l",
+							fmt.Sprintf("shard=%s", s),
+						)
+						g.Expect(err).ToNot(HaveOccurred())
+						regexMatcher := matchers.MatchRegexpMatcher{
+							Regexp: fmt.Sprintf(`Reconciling GitRepo.*"name":"%s"`, gitrepoName),
+						}
+						hasReconciledGitRepo, err := regexMatcher.Match(logs)
+						g.Expect(err).ToNot(HaveOccurred())
+						if s == shard {
+							g.Expect(hasReconciledGitRepo).To(BeTrueBecause(
+								"GitRepo %q labeled with shard %q should have been"+
+									" deployed by controller for shard %q in namespace %q",
+								gitrepoName,
+								shard,
+								shard,
+								env.Namespace,
+							))
+						} else {
+							g.Expect(hasReconciledGitRepo).To(BeFalseBecause(
+								"GitRepo %q labeled with shard %q should not have been"+
+									" deployed by controller for shard %q",
+								gitrepoName,
+								shard,
+								s,
+							))
+						}
+					}).Should(Succeed())
+				}
+			})
+
+			AfterEach(func() {
+				_ = os.RemoveAll(tmpDir)
+				_, _ = k.Delete("gitrepo", gitrepoName)
+			})
+		})
+	}
+
+	When("deploying a gitrepo labeled with an unknown shard ID", func() {
+		JustBeforeEach(func() {
+			targetNamespace = testenv.NewNamespaceName("target", r)
+			gitrepoName = testenv.RandomFilename("sharding-test", r)
+
+			err := testenv.ApplyTemplate(k, testenv.AssetPath("gitrepo/gitrepo_sharded.yaml"), struct {
+				Name            string
+				Repo            string
+				Branch          string
+				PollingInterval string
+				TargetNamespace string
+				ShardID         string
+			}{
+				gitrepoName,
+				inClusterRepoURL,
+				gh.Branch,
+				"15s",           // default
+				targetNamespace, // to avoid conflicts with other tests
+				"unknown",
+			})
+			Expect(err).ToNot(HaveOccurred())
+		})
+
+		It("does not deploy the gitrepo", func() {
+			By("checking the pod does not exist")
+			Eventually(func() string {
+				out, _ := k.Namespace(targetNamespace).Get("pods")
+				return out
+			}).ShouldNot(ContainSubstring("sleeper-"))
+
+			for _, s := range shards {
+				logs, err := k.Namespace("cattle-fleet-system").Logs(
+					"-l",
+					"app=fleet-controller",
+					"-l",
+					fmt.Sprintf("shard=%s", s),
+				)
+				Expect(err).ToNot(HaveOccurred())
+				regexMatcher := matchers.MatchRegexpMatcher{
+					Regexp: fmt.Sprintf(
+						`Reconciling GitRepo.*"GitRepo": {"name":"%s","namespace":"%s"}`,
+						gitrepoName,
+						env.Namespace,
+					),
+				}
+				hasReconciledGitRepos, err := regexMatcher.Match(logs)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(hasReconciledGitRepos).To(BeFalseBecause(
+					"GitRepo labeled with shard %q should not have been deployed by"+
+						" controller for shard %q",
+					"unknown",
+					s,
+				))
+			}
+		})
+
+		AfterEach(func() {
+			_ = os.RemoveAll(tmpDir)
+			_, _ = k.Delete("gitrepo", gitrepoName)
+		})
+	})
+})
diff --git a/e2e/testenv/env.go b/e2e/testenv/env.go
index f0031778..013f8c02 100644
--- a/e2e/testenv/env.go
+++ b/e2e/testenv/env.go
@@ -65,3 +65,14 @@ func NewNamespaceName(name string, s rand.Source) string {
 	}
 	return fmt.Sprintf("test-%.20s-%.12s", name, hex.EncodeToString(p))
 }
+
+// AddRandomSuffix adds a random suffix to a given name.
+func AddRandomSuffix(name string, s rand.Source) string {
+	p := make([]byte, 6)
+	r := rand.New(s) // nolint:gosec // non-crypto usage
+	_, err := r.Read(p)
+	if err != nil {
+		panic(err)
+	}
+	return fmt.Sprintf("%s-%s", name, hex.EncodeToString(p))
+}
diff --git a/e2e/testenv/infra/go.mod b/e2e/testenv/infra/go.mod
index d2718236..c10d9d5b 100644
--- a/e2e/testenv/infra/go.mod
+++ b/e2e/testenv/infra/go.mod
@@ -2,7 +2,7 @@ module github.com/rancher/fleet/e2e/testenv/infra
 
 go 1.22
 
-toolchain go1.22.0
+toolchain go1.22.2
 
 replace k8s.io/client-go => k8s.io/client-go v0.29.0
 
diff --git a/e2e/testenv/template.go b/e2e/testenv/template.go
index 29743698..e012b160 100644
--- a/e2e/testenv/template.go
+++ b/e2e/testenv/template.go
@@ -15,6 +15,7 @@ import (
 )
 
 const gitrepoTemplate = "gitrepo-template.yaml"
+const clustergroupTemplate = "clustergroup-template.yaml"
 
 // GitRepoData can be used with the gitrepo-template.yaml asset when no custom
 // GitRepo properties are required. All fields are required.
@@ -35,12 +36,31 @@ func CreateGitRepo(k kubectl.Command, namespace string, name string, branch stri
 	})
 }
 
+// CreateClusterGroup uses the template to create a clustergroup resource.
+func CreateClusterGroup(
+	k kubectl.Command,
+	namespace,
+	name string,
+	labels map[string]string,
+) error {
+	return ApplyTemplate(k, AssetPath(clustergroupTemplate), map[string]interface{}{
+		"Name":        name,
+		"Namespace":   namespace,
+		"MatchLabels": labels,
+	})
+}
+
 // ApplyTemplate templates a file and applies it to the cluster.
 func ApplyTemplate(k kubectl.Command, asset string, data interface{}) error {
 	tmpdir, _ := os.MkdirTemp("", "fleet-")
 	defer os.RemoveAll(tmpdir)
 
-	output := path.Join(tmpdir, RandomFilename(asset, rand.New(rand.NewSource(ginkgo.GinkgoRandomSeed())))) // nolint:gosec // test code
+	output := path.Join(
+		tmpdir, RandomFilename(
+			asset,
+			rand.New(rand.NewSource(ginkgo.GinkgoRandomSeed())), // nolint:gosec // test code
+		),
+	)
 	if err := Template(output, AssetPath(asset), data); err != nil {
 		return err
 	}
diff --git a/integrationtests/gitcloner/clone_test.go b/integrationtests/gitcloner/clone_test.go
index a3ee4fcc..592d5f48 100644
--- a/integrationtests/gitcloner/clone_test.go
+++ b/integrationtests/gitcloner/clone_test.go
@@ -14,8 +14,7 @@ import (
 	"strings"
 	"time"
 
-	"github.com/rancher/fleet/cmd/gitcloner/cmd"
-	"github.com/rancher/fleet/cmd/gitcloner/gogit"
+	"github.com/rancher/fleet/internal/cmd/cli/gitcloner"
 
 	dockercontainer "github.com/docker/docker/api/types/container"
 	dockermount "github.com/docker/docker/api/types/mount"
@@ -24,11 +23,12 @@ import (
 	"github.com/go-git/go-git/v5/plumbing/object"
 	httpgit "github.com/go-git/go-git/v5/plumbing/transport/http"
 	"github.com/gogits/go-gogs-client"
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
 	cp "github.com/otiai10/copy"
 	"github.com/testcontainers/testcontainers-go"
 	"golang.org/x/crypto/ssh"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
 )
 
 /*
@@ -53,7 +53,7 @@ var (
 var _ = Describe("Applying a git job gets content from git repo", Ordered, func() {
 
 	var (
-		opts          *cmd.Options
+		opts          *gitcloner.GitCloner
 		private       bool
 		cloneErr      error
 		tmp           string
@@ -87,14 +87,14 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 		When("cloning a public repo that contains a README.md file providing a branch", func() {
 			BeforeEach(func() {
 				private = false
-				opts = &cmd.Options{
+				opts = &gitcloner.GitCloner{
 					InsecureSkipTLS: true,
 					Branch:          "master",
 				}
 			})
 
 			JustBeforeEach(func() {
-				c := gogit.NewCloner()
+				c := gitcloner.New()
 				cloneErr = c.CloneRepo(opts)
 				Expect(cloneErr).NotTo(HaveOccurred())
 			})
@@ -108,13 +108,13 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 		When("cloning a public repo that contains a README.md file providing a revision", func() {
 			BeforeEach(func() {
 				private = false
-				opts = &cmd.Options{
+				opts = &gitcloner.GitCloner{
 					InsecureSkipTLS: true,
 				}
 			})
 
 			JustBeforeEach(func() {
-				c := gogit.NewCloner()
+				c := gitcloner.New()
 				opts.Revision = initialCommit
 				cloneErr = c.CloneRepo(opts)
 				Expect(cloneErr).NotTo(HaveOccurred())
@@ -130,13 +130,13 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 			When("No authentication is provided", func() {
 				BeforeEach(func() {
 					private = true
-					opts = &cmd.Options{
+					opts = &gitcloner.GitCloner{
 						InsecureSkipTLS: true,
 					}
 				})
 
 				JustBeforeEach(func() {
-					c := gogit.NewCloner()
+					c := gitcloner.New()
 					cloneErr = c.CloneRepo(opts)
 				})
 
@@ -148,7 +148,7 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 			When("Basic authentication is provided", func() {
 				BeforeEach(func() {
 					private = true
-					opts = &cmd.Options{
+					opts = &gitcloner.GitCloner{
 						InsecureSkipTLS: true,
 						Username:        gogsUser,
 						PasswordFile:    "assets/gogs/password",
@@ -156,7 +156,7 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 				})
 
 				JustBeforeEach(func() {
-					c := gogit.NewCloner()
+					c := gitcloner.New()
 					cloneErr = c.CloneRepo(opts)
 					Expect(cloneErr).NotTo(HaveOccurred())
 				})
@@ -171,11 +171,11 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 		When("Insecure skip tls is not true, and no caBundle is provided", func() {
 			BeforeEach(func() {
 				private = false
-				opts = &cmd.Options{}
+				opts = &gitcloner.GitCloner{}
 			})
 
 			JustBeforeEach(func() {
-				c := gogit.NewCloner()
+				c := gitcloner.New()
 				cloneErr = c.CloneRepo(opts)
 			})
 
@@ -189,7 +189,7 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 
 			BeforeEach(func() {
 				private = false
-				opts = &cmd.Options{}
+				opts = &gitcloner.GitCloner{}
 			})
 
 			AfterEach(func() {
@@ -205,7 +205,7 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 				Expect(err).NotTo(HaveOccurred())
 				opts.CABundleFile = caBundleFile.Name()
 
-				c := gogit.NewCloner()
+				c := gitcloner.New()
 				cloneErr = c.CloneRepo(opts)
 			})
 
@@ -249,7 +249,7 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 		When("the cloned repo is private and contains a README.md file", func() {
 			BeforeEach(func() {
 				private = true
-				opts = &cmd.Options{
+				opts = &gitcloner.GitCloner{
 					InsecureSkipTLS: true,
 				}
 			})
@@ -260,7 +260,7 @@ var _ = Describe("Applying a git job gets content from git repo", Ordered, func(
 			})
 
 			JustBeforeEach(func() {
-				c := gogit.NewCloner()
+				c := gitcloner.New()
 				Eventually(func() error {
 					return c.CloneRepo(opts)
 				}).ShouldNot(HaveOccurred())
diff --git a/integrationtests/gitjob/controller/suite_test.go b/integrationtests/gitjob/controller/suite_test.go
index d7ece82a..a335e236 100644
--- a/integrationtests/gitjob/controller/suite_test.go
+++ b/integrationtests/gitjob/controller/suite_test.go
@@ -11,7 +11,7 @@ import (
 
 	"go.uber.org/mock/gomock"
 
-	"github.com/rancher/fleet/internal/cmd/gitjob"
+	"github.com/rancher/fleet/internal/cmd/controller/gitops/reconciler"
 	"github.com/rancher/fleet/internal/mocks"
 	v1alpha1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 
@@ -71,7 +71,7 @@ var _ = BeforeSuite(func() {
 	gitPollerMock.EXPECT().AddOrModifyGitRepoPollJob(gomock.Any(), gomock.Any()).AnyTimes()
 	gitPollerMock.EXPECT().CleanUpGitRepoPollJobs(gomock.Any()).AnyTimes()
 
-	err = (&gitjob.GitJobReconciler{
+	err = (&reconciler.GitJobReconciler{
 		Client:    mgr.GetClient(),
 		Scheme:    mgr.GetScheme(),
 		Image:     "image",
diff --git a/cmd/gitcloner/gogit/cloner_test.go b/internal/cmd/cli/gitcloner/cloner_test.go
similarity index 95%
rename from cmd/gitcloner/gogit/cloner_test.go
rename to internal/cmd/cli/gitcloner/cloner_test.go
index 68e9430d..8ca0e1ae 100644
--- a/cmd/gitcloner/gogit/cloner_test.go
+++ b/internal/cmd/cli/gitcloner/cloner_test.go
@@ -1,18 +1,16 @@
-package gogit
+package gitcloner
 
 import (
 	"errors"
 	"os"
 	"testing"
 
+	"github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
 	"github.com/go-git/go-git/v5/plumbing/transport"
-
-	"github.com/go-git/go-git/v5"
 	httpgit "github.com/go-git/go-git/v5/plumbing/transport/http"
 	gossh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
 	"github.com/google/go-cmp/cmp"
-	"github.com/rancher/fleet/cmd/gitcloner/cmd"
 )
 
 func TestCloneRepo(t *testing.T) {
@@ -74,12 +72,12 @@ udiSlDctMM/X3ZM2JN5M1rtAJ2WR3ZQtmWbOjZAbG2Eq
 	}()
 
 	tests := map[string]struct {
-		opts              *cmd.Options
+		opts              *GitCloner
 		expectedCloneOpts *git.CloneOptions
 		expectedErr       error
 	}{
 		"branch no auth": {
-			opts: &cmd.Options{
+			opts: &GitCloner{
 				Repo:   "repo",
 				Path:   "path",
 				Branch: "master",
@@ -91,7 +89,7 @@ udiSlDctMM/X3ZM2JN5M1rtAJ2WR3ZQtmWbOjZAbG2Eq
 			},
 		},
 		"branch basic auth": {
-			opts: &cmd.Options{
+			opts: &GitCloner{
 				Repo:         "repo",
 				Path:         "path",
 				Branch:       "master",
@@ -109,7 +107,7 @@ udiSlDctMM/X3ZM2JN5M1rtAJ2WR3ZQtmWbOjZAbG2Eq
 			},
 		},
 		"branch ssh auth": {
-			opts: &cmd.Options{
+			opts: &GitCloner{
 				Repo:              "ssh://git@localhost/test/test-repo",
 				Path:              "path",
 				Branch:            "master",
@@ -123,7 +121,7 @@ udiSlDctMM/X3ZM2JN5M1rtAJ2WR3ZQtmWbOjZAbG2Eq
 			},
 		},
 		"password file does not exist": {
-			opts: &cmd.Options{
+			opts: &GitCloner{
 				Repo:         "repo",
 				Branch:       "master",
 				PasswordFile: "doesntexist",
@@ -133,7 +131,7 @@ udiSlDctMM/X3ZM2JN5M1rtAJ2WR3ZQtmWbOjZAbG2Eq
 			expectedErr:       errors.New("file not found"),
 		},
 		"ca file does not exist": {
-			opts: &cmd.Options{
+			opts: &GitCloner{
 				Repo:         "repo",
 				Branch:       "master",
 				CABundleFile: "doesntexist",
@@ -142,7 +140,7 @@ udiSlDctMM/X3ZM2JN5M1rtAJ2WR3ZQtmWbOjZAbG2Eq
 			expectedErr:       errors.New("file not found"),
 		},
 		"ssh private key file does not exist": {
-			opts: &cmd.Options{
+			opts: &GitCloner{
 				Repo:              "repo",
 				Branch:            "master",
 				SSHPrivateKeyFile: "doesntexist",
diff --git a/cmd/gitcloner/cmd/cmd_test.go b/internal/cmd/cli/gitcloner/cmd_test.go
similarity index 92%
rename from cmd/gitcloner/cmd/cmd_test.go
rename to internal/cmd/cli/gitcloner/cmd_test.go
index 89e3b79d..8d8c7480 100644
--- a/cmd/gitcloner/cmd/cmd_test.go
+++ b/internal/cmd/cli/gitcloner/cmd_test.go
@@ -1,4 +1,4 @@
-package cmd
+package gitcloner
 
 import (
 	"testing"
@@ -6,7 +6,7 @@ import (
 
 func TestArgsAreSet(t *testing.T) {
 	mock := &clonerMock{}
-	cmd := New(mock)
+	cmd := NewCmd(mock)
 	cmd.SetArgs([]string{"test-repo", "test-path", "--branch", "master", "--revision", "v0.1.0", "--ca-bundle-file", "caFile", "--username", "user",
 		"--password-file", "passwordFile", "--ssh-private-key-file", "sshFile", "--insecure-skip-tls", "--known-hosts-file", "knownFile"})
 	err := cmd.Execute()
@@ -43,10 +43,10 @@ func TestArgsAreSet(t *testing.T) {
 }
 
 type clonerMock struct {
-	opts *Options
+	opts *GitCloner
 }
 
-func (m *clonerMock) CloneRepo(opts *Options) error {
+func (m *clonerMock) CloneRepo(opts *GitCloner) error {
 	m.opts = opts
 
 	return nil
diff --git a/internal/cmd/gitjob/gitjob_test.go b/internal/cmd/controller/gitops/reconciler/gitjob_test.go
similarity index 96%
rename from internal/cmd/gitjob/gitjob_test.go
rename to internal/cmd/controller/gitops/reconciler/gitjob_test.go
index cfd0f2ca..68e19782 100644
--- a/internal/cmd/gitjob/gitjob_test.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob_test.go
@@ -1,7 +1,7 @@
-//go:generate mockgen --build_flags=--mod=mod -destination=../../mocks/poller_mock.go -package=mocks github.com/rancher/fleet/internal/cmd/gitjob GitPoller
-//go:generate mockgen --build_flags=--mod=mod -destination=../../mocks/client_mock.go -package=mocks sigs.k8s.io/controller-runtime/pkg/client Client,SubResourceWriter
+//go:generate mockgen --build_flags=--mod=mod -destination=../../../../mocks/poller_mock.go -package=mocks github.com/rancher/fleet/internal/cmd/controller/gitops/reconciler GitPoller
+//go:generate mockgen --build_flags=--mod=mod -destination=../../../../mocks/client_mock.go -package=mocks sigs.k8s.io/controller-runtime/pkg/client Client,SubResourceWriter
 
-package gitjob
+package reconciler
 
 import (
 	"context"
@@ -120,9 +120,9 @@ func TestNewJob(t *testing.T) { // nolint:funlen
 			expectedInitContainers: []corev1.Container{
 				{
 					Command: []string{
-						"gitcloner",
+						"fleet",
 					},
-					Args:  []string{"repo", "/workspace", "--branch", "master"},
+					Args:  []string{"gitcloner", "repo", "/workspace", "--branch", "master"},
 					Image: "test",
 					Name:  "gitcloner-initializer",
 					VolumeMounts: []corev1.VolumeMount{
@@ -164,9 +164,9 @@ func TestNewJob(t *testing.T) { // nolint:funlen
 			expectedInitContainers: []corev1.Container{
 				{
 					Command: []string{
-						"gitcloner",
+						"fleet",
 					},
-					Args:  []string{"repo", "/workspace", "--branch", "foo"},
+					Args:  []string{"gitcloner", "repo", "/workspace", "--branch", "foo"},
 					Image: "test",
 					Name:  "gitcloner-initializer",
 					VolumeMounts: []corev1.VolumeMount{
@@ -208,9 +208,9 @@ func TestNewJob(t *testing.T) { // nolint:funlen
 			expectedInitContainers: []corev1.Container{
 				{
 					Command: []string{
-						"gitcloner",
+						"fleet",
 					},
-					Args:  []string{"repo", "/workspace", "--revision", "foo"},
+					Args:  []string{"gitcloner", "repo", "/workspace", "--revision", "foo"},
 					Image: "test",
 					Name:  "gitcloner-initializer",
 					VolumeMounts: []corev1.VolumeMount{
@@ -252,9 +252,10 @@ func TestNewJob(t *testing.T) { // nolint:funlen
 			expectedInitContainers: []corev1.Container{
 				{
 					Command: []string{
-						"gitcloner",
+						"fleet",
 					},
 					Args: []string{
+						"gitcloner",
 						"repo",
 						"/workspace",
 						"--branch",
@@ -317,9 +318,10 @@ func TestNewJob(t *testing.T) { // nolint:funlen
 			expectedInitContainers: []corev1.Container{
 				{
 					Command: []string{
-						"gitcloner",
+						"fleet",
 					},
 					Args: []string{
+						"gitcloner",
 						"repo",
 						"/workspace",
 						"--branch",
@@ -380,9 +382,10 @@ func TestNewJob(t *testing.T) { // nolint:funlen
 			expectedInitContainers: []corev1.Container{
 				{
 					Command: []string{
-						"gitcloner",
+						"fleet",
 					},
 					Args: []string{
+						"gitcloner",
 						"repo",
 						"/workspace",
 						"--branch",
@@ -442,9 +445,10 @@ func TestNewJob(t *testing.T) { // nolint:funlen
 			expectedInitContainers: []corev1.Container{
 				{
 					Command: []string{
-						"gitcloner",
+						"fleet",
 					},
 					Args: []string{
+						"gitcloner",
 						"repo",
 						"/workspace",
 						"--branch",
diff --git a/internal/cmd/controller/summary/summary_test.go b/internal/cmd/controller/summary/summary_test.go
new file mode 100644
index 00000000..ac06c65e
--- /dev/null
+++ b/internal/cmd/controller/summary/summary_test.go
@@ -0,0 +1,64 @@
+package summary_test
+
+import (
+	"testing"
+
+	"github.com/rancher/fleet/internal/cmd/controller/summary"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+)
+
+func TestGetSummaryState(t *testing.T) {
+	// It is supposed to return an empty string if there are no non-ready
+	// resources, independent of the state of the bundle.
+	s := fleet.BundleSummary{
+		Modified:     1,
+		Pending:      2,
+		WaitApplied:  3,
+		ErrApplied:   4,
+		NotReady:     5,
+		OutOfSync:    6,
+		Ready:        7,
+		DesiredReady: 8,
+	}
+	bundleState := summary.GetSummaryState(s)
+	if string(bundleState) != "" {
+		t.Errorf("Expected empty string, got %s", bundleState)
+	}
+
+	// It is supposed to return "Modified" if there is a non-ready resource in
+	// state Modified.
+	s.NonReadyResources = []fleet.NonReadyResource{
+		{
+			Name:  "test",
+			State: fleet.Modified,
+		},
+	}
+	bundleState = summary.GetSummaryState(s)
+	if bundleState != fleet.Modified {
+		t.Errorf("Expected Modified, got %s", bundleState)
+	}
+
+	// It is supposed to return the highest priority state if there are multiple
+	// non-ready resources. Rank depends on v1alpha1.StateRank.
+	// ErrApplied:  7,
+	// WaitApplied: 6,
+	// Modified:    5,
+	// OutOfSync:   4,
+	// Pending:     3,
+	// NotReady:    2,
+	// Ready:       1,
+	s.NonReadyResources = []fleet.NonReadyResource{
+		{
+			Name:  "test",
+			State: fleet.Pending,
+		},
+		{
+			Name:  "test",
+			State: fleet.WaitApplied,
+		},
+	}
+	bundleState = summary.GetSummaryState(s)
+	if bundleState != fleet.WaitApplied {
+		t.Errorf("Expected WaitApplied, got %s", bundleState)
+	}
+}
diff --git a/internal/manifest/store_test.go b/internal/manifest/store_test.go
index d4024b68..e012bf54 100644
--- a/internal/manifest/store_test.go
+++ b/internal/manifest/store_test.go
@@ -1,7 +1,5 @@
 package manifest
 
-//go:generate mockgen --build_flags=--mod=mod -destination=../cmd/controller/mocks/client_mock.go -package=mocks sigs.k8s.io/controller-runtime/pkg/client Client
-
 import (
 	"bytes"
 	"context"