Repository: rancher/fleet
Pull request #2664 (2024-07-23 09:22:51)

Simplify target customisations for namespace labels and annotations
Follow-up to #2583.
Refers to #2262 and #2441.

This simplifies end-to-end tests and the implementation for using namespace labels and annotations as target customisations.

In bundle deployment options, maps are used directly instead of pointers to maps for namespace labels and namespace annotations.
Manual tests suggest that Kubernetes represents bundles deployments with identical YAML in both cases, ie whether pointers to maps or simple maps are used to represent those fields.



Patch:
diff --git a/.github/scripts/deploy-fleet.sh b/.github/scripts/deploy-fleet.sh
index f8acca08..00a8a247 100755
--- a/.github/scripts/deploy-fleet.sh
+++ b/.github/scripts/deploy-fleet.sh
@@ -41,7 +41,7 @@ if [ -n "$shards_json" ]; then
       for key in $(echo "$node_selector" | jq -r 'keys[]'); do
         value=$(echo "$node_selector" | jq -r --arg key "$key" '.[$key]')
         escaped_key=$(echo "$key" | sed 's/\./\\./g')
-        shards_settings="$shards_settings --set shards[$index].nodeSelector.\"$escaped_key\"=$value"
+        shards_settings="$shards_settings --set shards[$index].nodeSelector.$escaped_key=$value"
       done
     fi
     index=$((index + 1))
@@ -66,6 +66,7 @@ eventually helm upgrade --install fleet charts/fleet \
   $shards_settings \
   --set-string extraEnv[0].name=EXPERIMENTAL_OCI_STORAGE \
   --set-string extraEnv[0].value=true \
+  --set garbageCollectionInterval=1s \
   --set debug=true --set debugLevel=1
 
 # wait for controller and agent rollout
diff --git a/.github/scripts/release-against-charts.sh b/.github/scripts/release-against-charts.sh
index 36ecf21b..fe33056d 100755
--- a/.github/scripts/release-against-charts.sh
+++ b/.github/scripts/release-against-charts.sh
@@ -52,6 +52,7 @@ if [ "${REPLACE}" == "true" ]; then
 fi
 
 PACKAGE=fleet make charts
+git checkout release.yaml   # reset unwanted changes to release.yaml, relevant ones are already part of the previous commit
 git add assets/fleet* charts/fleet* index.yaml
 git commit -m "Autogenerated changes for Fleet v${NEW_FLEET_VERSION}"
 
diff --git a/.github/workflows/release-fleet.yml b/.github/workflows/release-fleet.yml
index 64c579ba..3c1230ad 100644
--- a/.github/workflows/release-fleet.yml
+++ b/.github/workflows/release-fleet.yml
@@ -41,12 +41,12 @@ jobs:
             secret/data/github/repo/${{ github.repository }}/fossa/credential token | FOSSA_API_KEY
 
       - name: Run FOSSA scan
-        uses: fossas/fossa-action@v1.3.3
+        uses: fossas/fossa-action@v1.4.0
         with:
           api-key: ${{ env.FOSSA_API_KEY }}
 
       - name: Run FOSSA tests
-        uses: fossas/fossa-action@v1.3.3
+        uses: fossas/fossa-action@v1.4.0
         with:
           api-key: ${{ env.FOSSA_API_KEY }}
           run-tests: false
diff --git a/charts/fleet-agent/templates/configmap.yaml b/charts/fleet-agent/templates/configmap.yaml
index f3e83a89..5b052e87 100644
--- a/charts/fleet-agent/templates/configmap.yaml
+++ b/charts/fleet-agent/templates/configmap.yaml
@@ -10,4 +10,7 @@ data:
       {{ end }}
       "clientID":"{{.Values.clientID}}",
       "agentTLSMode": "{{.Values.agentTLSMode}}"
+      {{ if .Values.garbageCollectionInterval }}
+      "garbageCollectionInterval": "{{.Values.garbageCollectionInterval}}"
+      {{ end }}
     }
diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml
index f20173ca..235b4892 100644
--- a/charts/fleet-crd/templates/crds.yaml
+++ b/charts/fleet-crd/templates/crds.yaml
@@ -5656,6 +5656,10 @@ spec:
                       nullable: true
                       type: string
                   type: object
+                garbageCollectionInterval:
+                  description: GarbageCollectionInterval determines how often agents
+                    clean up obsolete Helm releases.
+                  type: string
                 namespace:
                   description: 'Namespace is the cluster namespace, it contains the
                     clusters service
diff --git a/charts/fleet/templates/configmap.yaml b/charts/fleet/templates/configmap.yaml
index 44774021..e2af6ca7 100644
--- a/charts/fleet/templates/configmap.yaml
+++ b/charts/fleet/templates/configmap.yaml
@@ -12,6 +12,9 @@ data:
       "apiServerCA": "{{b64enc .Values.apiServerCA}}",
       "agentCheckinInterval": "{{.Values.agentCheckinInterval}}",
       "agentTLSMode": "{{.Values.agentTLSMode}}",
+      {{ if .Values.garbageCollectionInterval }}
+      "garbageCollectionInterval": "{{.Values.garbageCollectionInterval}}",
+      {{ end }}
       "ignoreClusterRegistrationLabels": {{.Values.ignoreClusterRegistrationLabels}},
       "bootstrap": {
         "paths": "{{.Values.bootstrap.paths}}",
diff --git a/charts/fleet/templates/deployment_gitjob.yaml b/charts/fleet/templates/deployment_gitjob.yaml
index 66c37e40..8cacdded 100644
--- a/charts/fleet/templates/deployment_gitjob.yaml
+++ b/charts/fleet/templates/deployment_gitjob.yaml
@@ -51,6 +51,10 @@ spec:
           - --shard-id
           - {{ quote $shard.id }}
           {{- end }}
+          {{- if $shard.nodeSelector }}
+          - --shard-node-selector
+          - {{ toJson $shard.nodeSelector | squote }}
+          {{- end }}
           {{- if not $.Values.metrics.enabled }}
           - --disable-metrics
           {{- end }}
diff --git a/dev/setup-fleet b/dev/setup-fleet
index adc50791..60895e83 100755
--- a/dev/setup-fleet
+++ b/dev/setup-fleet
@@ -29,7 +29,7 @@ if [ -n "$shards_json" ]; then
       for key in $(echo "$node_selector" | jq -r 'keys[]'); do
         value=$(echo "$node_selector" | jq -r --arg key "$key" '.[$key]')
         escaped_key=$(echo "$key" | sed 's/\./\\./g')
-        shards_settings="$shards_settings --set shards[$index].nodeSelector.\"$escaped_key\"=$value"
+        shards_settings="$shards_settings --set shards[$index].nodeSelector.$escaped_key=$value"
       done
     fi
     index=$((index + 1))
@@ -42,6 +42,7 @@ helm -n cattle-fleet-system upgrade --install --create-namespace --wait --reset-
   $shards_settings \
   --set-string extraEnv[0].name=EXPERIMENTAL_OCI_STORAGE \
   --set-string extraEnv[0].value=true \
+  --set garbageCollectionInterval=1s \
   --set debug=true --set debugLevel=1 fleet charts/fleet
 
 # wait for controller and agent rollout
diff --git a/go.mod b/go.mod
index 497c8c29..d6ebc178 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,7 @@ module github.com/rancher/fleet
 
 go 1.22.0
 
-toolchain go1.22.5
+toolchain go1.22.6
 
 replace (
 	github.com/imdario/mergo => github.com/imdario/mergo v0.3.16
@@ -15,22 +15,22 @@ 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 v27.1.0+incompatible
+	github.com/docker/docker v27.1.1+incompatible
 	github.com/evanphx/json-patch v5.9.0+incompatible
 	github.com/go-git/go-billy/v5 v5.5.1-0.20240701174852-839dcd3ad096
 	github.com/go-git/go-git/v5 v5.12.1-0.20240629213349-b00c68ab7140
 	github.com/go-logr/logr v1.4.2
-	github.com/go-playground/webhooks/v6 v6.3.0
+	github.com/go-playground/webhooks/v6 v6.4.0
 	github.com/gobwas/glob v0.2.3
 	github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
 	github.com/golang/mock v1.6.0
 	github.com/google/go-cmp v0.6.0
-	github.com/google/go-containerregistry v0.20.1
+	github.com/google/go-containerregistry v0.20.2
 	github.com/gorilla/mux v1.8.1
 	github.com/hashicorp/go-getter v1.7.5
 	github.com/jpillora/backoff v1.0.0
-	github.com/onsi/ginkgo/v2 v2.19.0
-	github.com/onsi/gomega v1.33.1
+	github.com/onsi/ginkgo/v2 v2.20.0
+	github.com/onsi/gomega v1.34.1
 	github.com/opencontainers/go-digest v1.0.0
 	github.com/opencontainers/image-spec v1.1.0
 	github.com/otiai10/copy v1.14.0
@@ -48,8 +48,8 @@ require (
 	github.com/testcontainers/testcontainers-go v0.32.0
 	go.uber.org/mock v0.4.0
 	go.uber.org/zap v1.27.0
-	golang.org/x/crypto v0.25.0
-	golang.org/x/sync v0.7.0
+	golang.org/x/crypto v0.26.0
+	golang.org/x/sync v0.8.0
 	gopkg.in/go-playground/webhooks.v5 v5.17.0
 	gopkg.in/yaml.v2 v2.4.0
 	gotest.tools v2.2.0+incompatible
@@ -60,10 +60,10 @@ require (
 	k8s.io/cli-runtime v0.30.2
 	k8s.io/client-go v0.30.2
 	k8s.io/klog/v2 v2.130.1
-	k8s.io/kube-openapi v0.0.0-20240620174524-b456828f718b
+	k8s.io/kube-openapi v0.0.0-20240808142205-8e686545bdb8
 	k8s.io/kubectl v0.30.2
 	k8s.io/kubernetes v1.30.3
-	k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0
+	k8s.io/utils v0.0.0-20240711033017-18e509b52bc8
 	oras.land/oras-go/v2 v2.5.0
 	sigs.k8s.io/cli-utils v0.35.0
 	sigs.k8s.io/controller-runtime v0.18.4
@@ -88,7 +88,7 @@ require (
 	github.com/Microsoft/go-winio v0.6.2 // indirect
 	github.com/Microsoft/hcsshim v0.11.5 // indirect
 	github.com/ProtonMail/go-crypto v1.0.0 // indirect
-	github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
+	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
 	github.com/aws/aws-sdk-go v1.44.122 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
@@ -105,7 +105,7 @@ require (
 	github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
 	github.com/cyphar/filepath-securejoin v0.2.5 // indirect
 	github.com/distribution/reference v0.6.0 // indirect
-	github.com/docker/cli v25.0.1+incompatible // indirect
+	github.com/docker/cli v27.1.1+incompatible // indirect
 	github.com/docker/distribution v2.8.3+incompatible // indirect
 	github.com/docker/docker-credential-helpers v0.7.0 // indirect
 	github.com/docker/go-connections v0.5.0 // indirect
@@ -137,7 +137,7 @@ require (
 	github.com/google/btree v1.1.2 // indirect
 	github.com/google/gnostic-models v0.6.8 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
-	github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 // indirect
+	github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
 	github.com/google/s2a-go v0.1.7 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
 	github.com/google/uuid v1.6.0 // indirect
@@ -222,15 +222,15 @@ require (
 	go.opentelemetry.io/otel/trace v1.24.0 // indirect
 	go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
-	golang.org/x/mod v0.18.0 // indirect
-	golang.org/x/net v0.26.0 // indirect
+	golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+	golang.org/x/mod v0.20.0 // indirect
+	golang.org/x/net v0.28.0 // indirect
 	golang.org/x/oauth2 v0.21.0 // indirect
-	golang.org/x/sys v0.22.0 // indirect
-	golang.org/x/term v0.22.0 // indirect
-	golang.org/x/text v0.16.0 // indirect
+	golang.org/x/sys v0.23.0 // indirect
+	golang.org/x/term v0.23.0 // indirect
+	golang.org/x/text v0.17.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
-	golang.org/x/tools v0.22.0 // indirect
+	golang.org/x/tools v0.24.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
 	google.golang.org/api v0.128.0 // indirect
diff --git a/go.sum b/go.sum
index 63b35b58..d1c85e71 100644
--- a/go.sum
+++ b/go.sum
@@ -224,8 +224,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
-github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
-github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
 github.com/aws/aws-sdk-go v1.44.122 h1:p6mw01WBaNpbdP2xrisz5tIkcNwzj/HysobNoaAHjgo=
 github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -301,12 +301,12 @@ github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aB
 github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI=
 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
-github.com/docker/cli v25.0.1+incompatible h1:mFpqnrS6Hsm3v1k7Wa/BO23oz0k121MTbTO1lpcGSkU=
-github.com/docker/cli v25.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v27.1.1+incompatible h1:goaZxOqs4QKxznZjjBWKONQci/MywhtRv2oNn0GkeZE=
+github.com/docker/cli v27.1.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 v27.1.0+incompatible h1:rEHVQc4GZ0MIQKifQPHSFGV/dVgaZafgRf8fCPtDYBs=
-github.com/docker/docker v27.1.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
+github.com/docker/docker v27.1.1+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=
@@ -391,8 +391,8 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF
 github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
 github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
 github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
-github.com/go-playground/webhooks/v6 v6.3.0 h1:zBLUxK1Scxwi97TmZt5j/B/rLlard2zY7P77FHg58FE=
-github.com/go-playground/webhooks/v6 v6.3.0/go.mod h1:GCocmfMtpJdkEOM1uG9p2nXzg1kY5X/LtvQgtPHUaaA=
+github.com/go-playground/webhooks/v6 v6.4.0 h1:KLa6y7bD19N48rxJDHM0DpE3T4grV7GxMy1b/aHMWPY=
+github.com/go-playground/webhooks/v6 v6.4.0/go.mod h1:5lBxopx+cAJiBI4+kyRbuHrEi+hYRDdRHuRR4Ya5Ums=
 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
 github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -408,7 +408,6 @@ github.com/gobuffalo/packr/v2 v2.8.3 h1:xE1yzvnO56cUC0sTpKR3DIbxZgB54AftTFMhB2XE
 github.com/gobuffalo/packr/v2 v2.8.3/go.mod h1:0SahksCVcx4IMnigTjiFuyldmTrdTctXsOdiU5KwbKc=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
-github.com/gogits/go-gogs-client v0.0.0-20200905025246-8bb8a50cb355/go.mod h1:cY2AIrMgHm6oOHmR7jY+9TtjzSjQ3iG7tURJG3Y6XH0=
 github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 h1:04sojTxgYxu1L4Hn7Tgf7UVtIosVa6CuHtvNY+7T1K4=
 github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85/go.mod h1:cY2AIrMgHm6oOHmR7jY+9TtjzSjQ3iG7tURJG3Y6XH0=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -475,8 +474,8 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-containerregistry v0.20.1 h1:eTgx9QNYugV4DN5mz4U8hiAGTi1ybXn0TPi4Smd8du0=
-github.com/google/go-containerregistry v0.20.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
+github.com/google/go-containerregistry v0.20.2 h1:B1wPJ1SN/S7pB+ZAimcciVD+r+yV/l/DSArMxlbwseo=
+github.com/google/go-containerregistry v0.20.2/go.mod h1:z38EKdKh4h7IP2gSfUUqEvalZBqs6AoLeWfUy34nQC8=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
@@ -502,8 +501,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
-github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg=
-github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
+github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
+github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
 github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
@@ -698,10 +697,10 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
 github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
 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.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
-github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
-github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
-github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
+github.com/onsi/ginkgo/v2 v2.20.0 h1:PE84V2mHqoT1sglvHc8ZdQtPcwmvvt29WLEEO3xmdZw=
+github.com/onsi/ginkgo/v2 v2.20.0/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI=
+github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
+github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
 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=
@@ -889,8 +888,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
-golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
-golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
+golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -901,8 +900,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
 golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
-golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -930,8 +929,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
-golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
+golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -985,8 +984,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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
-golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
+golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
 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=
@@ -1029,8 +1028,8 @@ golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1112,8 +1111,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
+golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -1121,8 +1120,8 @@ golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
-golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
-golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
+golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
+golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1136,8 +1135,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
+golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1199,8 +1198,8 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
-golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
+golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
+golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1492,14 +1491,14 @@ k8s.io/gengo/v2 v2.0.0-20240228010128-51d4e06bde70/go.mod h1:VH3AT8AaQOqiGjMF9p0
 k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
 k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
 k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
-k8s.io/kube-openapi v0.0.0-20240620174524-b456828f718b h1:Q9xmGWBvOGd8UJyccgpYlLosk/JlfP3xQLNkQlHJeXw=
-k8s.io/kube-openapi v0.0.0-20240620174524-b456828f718b/go.mod h1:UxDHUPsUwTOOxSU+oXURfFBcAS6JwiRXTYqYwfuGowc=
+k8s.io/kube-openapi v0.0.0-20240808142205-8e686545bdb8 h1:1Wof1cGQgA5pqgo8MxKPtf+qN6Sh/0JzznmeGPm1HnE=
+k8s.io/kube-openapi v0.0.0-20240808142205-8e686545bdb8/go.mod h1:Os6V6dZwLNii3vxFpxcNaTmH8LJJBkOTg1N0tOA0fvA=
 k8s.io/kubectl v0.30.2 h1:cgKNIvsOiufgcs4yjvgkK0+aPCfa8pUwzXdJtkbhsH8=
 k8s.io/kubectl v0.30.2/go.mod h1:rz7GHXaxwnigrqob0lJsiA07Df8RE3n1TSaC2CTeuB4=
 k8s.io/kubernetes v1.30.3 h1:A0qoXI1YQNzrQZiff33y5zWxYHFT/HeZRK98/sRDJI0=
 k8s.io/kubernetes v1.30.3/go.mod h1:yPbIk3MhmhGigX62FLJm+CphNtjxqCvAIFQXup6RKS0=
-k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 h1:jgGTlFYnhF1PM1Ax/lAlxUPE+KfCIXHaathvJg1C3ak=
-k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
+k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo=
 oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo=
 oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c=
diff --git a/internal/cmd/agent/clusterstatus.go b/internal/cmd/agent/clusterstatus.go
index d8e5795c..6609b5cb 100644
--- a/internal/cmd/agent/clusterstatus.go
+++ b/internal/cmd/agent/clusterstatus.go
@@ -89,7 +89,7 @@ func (cs *ClusterStatus) Run(cmd *cobra.Command, args []string) error {
 
 	// cannot start without kubeconfig for upstream cluster
 	setupLog.Info("Fetching kubeconfig for upstream cluster from registration", "namespace", cs.Namespace)
-	agentInfo, err := register.Get(ctx, cs.Namespace, localConfig)
+	agentInfo, _, err := register.Get(ctx, cs.Namespace, localConfig)
 	if err != nil {
 		setupLog.Error(err, "failed to get kubeconfig from upstream cluster")
 		return err
diff --git a/internal/cmd/agent/deployer/cleanup/cleanup.go b/internal/cmd/agent/deployer/cleanup/cleanup.go
index 28ec23ce..d0df8564 100644
--- a/internal/cmd/agent/deployer/cleanup/cleanup.go
+++ b/internal/cmd/agent/deployer/cleanup/cleanup.go
@@ -7,6 +7,7 @@ import (
 	"time"
 
 	"github.com/go-logr/logr"
+	"helm.sh/helm/v3/pkg/action"
 
 	"github.com/rancher/fleet/internal/helmdeployer"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
@@ -26,26 +27,48 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/log"
 )
 
+type HelmDeployer interface {
+	NewListAction() *action.List
+	ListDeployments(list helmdeployer.ListAction) ([]helmdeployer.DeployedBundle, error)
+	DeleteRelease(ctx context.Context, deployed helmdeployer.DeployedBundle) error
+	Delete(ctx context.Context, name string) error
+}
+
 type Cleanup struct {
 	client           client.Client
 	fleetNamespace   string
 	defaultNamespace string
-	helmDeployer     *helmdeployer.Helm
+	helmDeployer     HelmDeployer
 	cleanupOnce      sync.Once
 
 	mapper meta.RESTMapper
 	// localDynamicClient is a dynamic client for the cluster the agent is running on (local cluster).
 	localDynamicClient *dynamic.DynamicClient
+
+	garbageCollectionInterval time.Duration
 }
 
-func New(upstream client.Client, mapper meta.RESTMapper, localDynamicClient *dynamic.DynamicClient, deployer *helmdeployer.Helm, fleetNamespace string, defaultNamespace string) *Cleanup {
+func New(
+	upstream client.Client,
+	mapper meta.RESTMapper,
+	localDynamicClient *dynamic.DynamicClient,
+	deployer HelmDeployer,
+	fleetNamespace string,
+	defaultNamespace string,
+	garbageCollectionInterval time.Duration,
+) *Cleanup {
+	if garbageCollectionInterval == 0 {
+		garbageCollectionInterval = durations.GarbageCollect
+	}
+
 	return &Cleanup{
-		client:             upstream,
-		mapper:             mapper,
-		localDynamicClient: localDynamicClient,
-		helmDeployer:       deployer,
-		fleetNamespace:     fleetNamespace,
-		defaultNamespace:   defaultNamespace,
+		client:                    upstream,
+		mapper:                    mapper,
+		localDynamicClient:        localDynamicClient,
+		helmDeployer:              deployer,
+		fleetNamespace:            fleetNamespace,
+		defaultNamespace:          defaultNamespace,
+		garbageCollectionInterval: garbageCollectionInterval,
 	}
 }
 
@@ -92,7 +115,7 @@ func (c *Cleanup) garbageCollect(ctx context.Context) {
 		select {
 		case <-ctx.Done():
 			return
-		case <-time.After(wait.Jitter(durations.GarbageCollect, 1.0)):
+		case <-time.After(wait.Jitter(c.garbageCollectionInterval, 1.0)):
 		}
 	}
 }
diff --git a/internal/cmd/agent/deployer/deployer.go b/internal/cmd/agent/deployer/deployer.go
index af025a90..bf4bcbfd 100644
--- a/internal/cmd/agent/deployer/deployer.go
+++ b/internal/cmd/agent/deployer/deployer.go
@@ -176,13 +176,13 @@ func (d *Deployer) setNamespaceLabelsAndAnnotations(ctx context.Context, bd *fle
 	}
 
 	if bd.Spec.Options.NamespaceLabels != nil {
-		addLabelsFromOptions(ns.Labels, *bd.Spec.Options.NamespaceLabels)
+		addLabelsFromOptions(ns.Labels, bd.Spec.Options.NamespaceLabels)
 	}
 	if bd.Spec.Options.NamespaceAnnotations != nil {
 		if ns.Annotations == nil {
 			ns.Annotations = map[string]string{}
 		}
-		addAnnotationsFromOptions(ns.Annotations, *bd.Spec.Options.NamespaceAnnotations)
+		addAnnotationsFromOptions(ns.Annotations, bd.Spec.Options.NamespaceAnnotations)
 	}
 	err = d.updateNamespace(ctx, ns)
 	if err != nil {
diff --git a/internal/cmd/agent/operator.go b/internal/cmd/agent/operator.go
index c2288f98..6348e425 100644
--- a/internal/cmd/agent/operator.go
+++ b/internal/cmd/agent/operator.go
@@ -13,6 +13,7 @@ import (
 	"github.com/rancher/fleet/internal/cmd/agent/deployer/monitor"
 	"github.com/rancher/fleet/internal/cmd/agent/register"
 	"github.com/rancher/fleet/internal/cmd/agent/trigger"
+	"github.com/rancher/fleet/internal/config"
 	"github.com/rancher/fleet/internal/helmdeployer"
 	"github.com/rancher/fleet/internal/manifest"
 	"github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
@@ -54,7 +55,7 @@ func start(ctx context.Context, localConfig *rest.Config, systemNamespace, agent
 	// Registration is done in an init container. If we are here, we are already registered.
 	// Retrieve the existing config from the registration.
 	// Cannot start without kubeconfig for upstream cluster:
-	upstreamConfig, fleetNamespace, clusterName, err := loadRegistration(ctx, systemNamespace, localConfig)
+	upstreamConfig, agentConfig, fleetNamespace, clusterName, err := loadRegistration(ctx, systemNamespace, localConfig)
 	if err != nil {
 		setupLog.Error(err, "unable to load registration and start manager")
 		return err
@@ -84,7 +85,16 @@ func start(ctx context.Context, localConfig *rest.Config, systemNamespace, agent
 	localCtx, cancel := context.WithCancel(ctx)
 	defer cancel()
 
-	reconciler, err := newReconciler(ctx, localCtx, mgr, localConfig, systemNamespace, fleetNamespace, agentScope)
+	reconciler, err := newReconciler(
+		ctx,
+		localCtx,
+		mgr,
+		localConfig,
+		systemNamespace,
+		fleetNamespace,
+		agentScope,
+		agentConfig,
+	)
 	if err != nil {
 		setupLog.Error(err, "unable to set up bundledeployment reconciler")
 		return err
@@ -116,29 +126,48 @@ func start(ctx context.Context, localConfig *rest.Config, systemNamespace, agent
 	return nil
 }
 
-func loadRegistration(ctx context.Context, systemNamespace string, localConfig *rest.Config) (*rest.Config, string, string, error) {
-	agentInfo, err := register.Get(ctx, systemNamespace, localConfig)
+func loadRegistration(
+	ctx context.Context,
+	systemNamespace string,
+	localConfig *rest.Config,
+) (*rest.Config, config.Config, string, string, error) {
+	agentInfo, agentConfig, err := register.Get(ctx, systemNamespace, localConfig)
 	if err != nil {
 		setupLog.Error(err, "cannot get registration info for upstream cluster")
-		return nil, "", "", err
+		return nil, config.Config{}, "", "", err
 	}
 
 	// fleetNamespace is the upstream cluster namespace from AgentInfo, e.g. cluster-fleet-ID
 	fleetNamespace, _, err := agentInfo.ClientConfig.Namespace()
 	if err != nil {
 		setupLog.Error(err, "cannot get upstream namespace from registration")
-		return nil, "", "", err
+		return nil, config.Config{}, "", "", err
 	}
 
 	upstreamConfig, err := agentInfo.ClientConfig.ClientConfig()
 	if err != nil {
 		setupLog.Error(err, "cannot get upstream kubeconfig from registration")
-		return nil, "", "", err
+		return nil, config.Config{}, "", "", err
 	}
-	return upstreamConfig, fleetNamespace, agentInfo.ClusterName, nil
+
+	if agentConfig == nil {
+		setupLog.Error(err, "unexpected nil agent config")
+		return nil, config.Config{}, "", "", err
+	}
+
+	return upstreamConfig, *agentConfig, fleetNamespace, agentInfo.ClusterName, nil
 }
 
-func newReconciler(ctx, localCtx context.Context, mgr manager.Manager, localConfig *rest.Config, systemNamespace, fleetNamespace, agentScope string) (*controller.BundleDeploymentReconciler, error) {
+func newReconciler(
+	ctx context.Context,
+	localCtx context.Context,
+	mgr manager.Manager,
+	localConfig *rest.Config,
+	systemNamespace string,
+	fleetNamespace string,
+	agentScope string,
+	agentConfig config.Config,
+) (*controller.BundleDeploymentReconciler, error) {
 	upstreamClient := mgr.GetClient()
 
 	// Build client for local cluster
@@ -215,6 +244,7 @@ func newReconciler(ctx, localCtx context.Context, mgr manager.Manager, localConf
 		helmDeployer,
 		fleetNamespace,
 		defaultNamespace,
+		agentConfig.GarbageCollectionInterval.Duration,
 	)
 
 	return &controller.BundleDeploymentReconciler{
diff --git a/internal/cmd/agent/register/register.go b/internal/cmd/agent/register/register.go
index 48f7a4a5..9eac5626 100644
--- a/internal/cmd/agent/register/register.go
+++ b/internal/cmd/agent/register/register.go
@@ -50,18 +50,18 @@ type AgentInfo struct {
 // Get a registration token from the local cluster and fail if it does not exist.
 // This does not create a new registration process and only works after
 // registration has been completed.
-func Get(ctx context.Context, namespace string, cfg *rest.Config) (*AgentInfo, error) {
+func Get(ctx context.Context, namespace string, cfg *rest.Config) (*AgentInfo, *config.Config, error) {
 	cfg = rest.CopyConfig(cfg)
 	// disable the rate limiter
 	cfg.RateLimiter = ratelimit.None
 	k8s, err := core.NewFactoryFromConfig(cfg)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	agentConfig, err := config.Lookup(ctx, namespace, config.AgentConfigName, k8s.Core().V1().ConfigMap())
 	if err != nil {
-		return nil, fmt.Errorf(
+		return nil, nil, fmt.Errorf(
 			"failed to look up client config %s/%s: %w",
 			namespace,
 			config.AgentConfigName,
@@ -76,22 +76,22 @@ func Get(ctx context.Context, namespace string, cfg *rest.Config) (*AgentInfo, e
 	secret, err := k8s.Core().V1().Secret().Get(namespace, CredName, metav1.GetOptions{})
 	if apierrors.IsNotFound(err) {
 		logrus.Warn("Cannot find fleet-agent secret")
-		return nil, err
+		return nil, nil, err
 	} else if err != nil {
 		logrus.Error("Cannot get fleet-agent secret")
-		return nil, err
+		return nil, nil, err
 	}
 
 	clientConfig, err := clientcmd.NewClientConfigFromBytes(secret.Data[Kubeconfig])
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	return &AgentInfo{
 		ClusterNamespace: string(secret.Data[ClusterNamespace]),
 		ClusterName:      string(secret.Data[ClusterName]),
 		ClientConfig:     clientConfig,
-	}, nil
+	}, agentConfig, nil
 }
 
 // Register creates a fleet-agent secret with the upstream kubeconfig, by
diff --git a/internal/cmd/controller/agentmanagement/agent/config.go b/internal/cmd/controller/agentmanagement/agent/config.go
index c88983a8..a944d261 100644
--- a/internal/cmd/controller/agentmanagement/agent/config.go
+++ b/internal/cmd/controller/agentmanagement/agent/config.go
@@ -12,9 +12,10 @@ import (
 )
 
 type ConfigOptions struct {
-	Labels       map[string]string
-	ClientID     string
-	AgentTLSMode string
+	Labels                    map[string]string
+	ClientID                  string
+	AgentTLSMode              string
+	GarbageCollectionInterval metav1.Duration
 }
 
 func agentConfig(ctx context.Context, agentNamespace, controllerNamespace string, cg *client.Getter, opts *ConfigOptions) ([]runtime.Object, error) {
@@ -38,9 +39,10 @@ func agentConfig(ctx context.Context, agentNamespace, controllerNamespace string
 
 func configObjects(controllerNamespace string, co *ConfigOptions) ([]runtime.Object, error) {
 	cm, err := config.ToConfigMap(controllerNamespace, config.AgentConfigName, &config.Config{
-		Labels:       co.Labels,
-		ClientID:     co.ClientID,
-		AgentTLSMode: co.AgentTLSMode,
+		Labels:                    co.Labels,
+		ClientID:                  co.ClientID,
+		AgentTLSMode:              co.AgentTLSMode,
+		GarbageCollectionInterval: co.GarbageCollectionInterval,
 	})
 	if err != nil {
 		return nil, err
diff --git a/internal/cmd/controller/agentmanagement/controllers/cluster/import.go b/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
index 4297fbb2..1df456a6 100644
--- a/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
+++ b/internal/cmd/controller/agentmanagement/controllers/cluster/import.go
@@ -97,7 +97,8 @@ func (i *importHandler) onConfig(config *config.Config) error {
 
 		hasConfigChanged := config.APIServerURL != cluster.Status.APIServerURL ||
 			hashStatusField(config.APIServerCA) != cluster.Status.APIServerCAHash ||
-			config.AgentTLSMode != cluster.Status.AgentTLSMode
+			config.AgentTLSMode != cluster.Status.AgentTLSMode ||
+			hasGarbageCollectionIntervalChanged(config, cluster)
 
 		if hasConfigChanged {
 			logrus.Infof("API server config changed, trigger cluster import for cluster %s/%s", cluster.Namespace, cluster.Name)
@@ -314,9 +315,10 @@ func (i *importHandler) importCluster(cluster *fleet.Cluster, status fleet.Clust
 			APIServerCA:  apiServerCA,
 			APIServerURL: apiServerURL,
 			ConfigOptions: agent.ConfigOptions{
-				ClientID:     cluster.Spec.ClientID,
-				Labels:       clusterLabels,
-				AgentTLSMode: cfg.AgentTLSMode,
+				ClientID:                  cluster.Spec.ClientID,
+				Labels:                    clusterLabels,
+				AgentTLSMode:              cfg.AgentTLSMode,
+				GarbageCollectionInterval: cfg.GarbageCollectionInterval,
 			},
 			ManifestOptions: agent.ManifestOptions{
 				AgentEnvVars:     cluster.Spec.AgentEnvVars,
@@ -385,6 +387,7 @@ func (i *importHandler) importCluster(cluster *fleet.Cluster, status fleet.Clust
 	status.APIServerURL = apiServerURL
 	status.APIServerCAHash = hashStatusField(apiServerCA)
 	status.AgentTLSMode = cfg.AgentTLSMode
+	status.GarbageCollectionInterval = &cfg.GarbageCollectionInterval
 
 	return status, nil
 }
@@ -432,3 +435,9 @@ func (i *importHandler) restConfigFromKubeConfig(data []byte, agentTLSMode strin
 
 	return clientcmd.NewDefaultClientConfig(raw, &clientcmd.ConfigOverrides{}).ClientConfig()
 }
+
+func hasGarbageCollectionIntervalChanged(config *config.Config, cluster fleet.Cluster) bool {
+	return (config.GarbageCollectionInterval.Duration != 0 && cluster.Status.GarbageCollectionInterval == nil) ||
+		(cluster.Status.GarbageCollectionInterval != nil &&
+			config.GarbageCollectionInterval.Duration != cluster.Status.GarbageCollectionInterval.Duration)
+}
diff --git a/internal/cmd/controller/agentmanagement/root.go b/internal/cmd/controller/agentmanagement/root.go
index f67831c6..3e307f81 100644
--- a/internal/cmd/controller/agentmanagement/root.go
+++ b/internal/cmd/controller/agentmanagement/root.go
@@ -23,6 +23,7 @@ func (a *AgentManagement) HelpFunc(cmd *cobra.Command, strings []string) {
 	_ = cmd.Flags().MarkHidden("disable-gitops")
 	_ = cmd.Flags().MarkHidden("disable-metrics")
 	_ = cmd.Flags().MarkHidden("shard-id")
+	_ = cmd.Flags().MarkHidden("shard-node-selector")
 	cmd.Parent().HelpFunc()(cmd, strings)
 }
 
diff --git a/internal/cmd/controller/cleanup/root.go b/internal/cmd/controller/cleanup/root.go
index 4347f4dc..deacf06e 100644
--- a/internal/cmd/controller/cleanup/root.go
+++ b/internal/cmd/controller/cleanup/root.go
@@ -18,6 +18,7 @@ func (c *CleanUp) HelpFunc(cmd *cobra.Command, strings []string) {
 	_ = cmd.Flags().MarkHidden("disable-gitops")
 	_ = cmd.Flags().MarkHidden("disable-metrics")
 	_ = cmd.Flags().MarkHidden("shard-id")
+	_ = cmd.Flags().MarkHidden("shard-node-selector")
 	cmd.Parent().HelpFunc()(cmd, strings)
 }
 
diff --git a/internal/cmd/controller/gitops/operator.go b/internal/cmd/controller/gitops/operator.go
index 85009026..30f4a03f 100644
--- a/internal/cmd/controller/gitops/operator.go
+++ b/internal/cmd/controller/gitops/operator.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/reugn/go-quartz/quartz"
 	"github.com/spf13/cobra"
+	"golang.org/x/sync/errgroup"
 	"k8s.io/apimachinery/pkg/runtime"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
@@ -19,7 +20,6 @@ import (
 	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"
-	"golang.org/x/sync/errgroup"
 
 	command "github.com/rancher/fleet/internal/cmd"
 	"github.com/rancher/fleet/internal/cmd/controller/gitops/reconciler"
@@ -52,6 +52,7 @@ type GitOperator struct {
 	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."`
 	ShardID              string `usage:"only manage resources labeled with a specific shard ID" name:"shard-id"`
+	ShardNodeSelector    string `usage:"node selector to apply to jobs based on the shard ID, if any" name:"shard-node-selector"`
 }
 
 func App(zo *zap.Options) *cobra.Command {
@@ -67,6 +68,7 @@ func (c *GitOperator) HelpFunc(cmd *cobra.Command, strings []string) {
 	_ = cmd.Flags().MarkHidden("disable-gitops")
 	_ = cmd.Flags().MarkHidden("disable-metrics")
 	_ = cmd.Flags().MarkHidden("shard-id")
+	_ = cmd.Flags().MarkHidden("shard-node-selector")
 	cmd.Parent().HelpFunc()(cmd, strings)
 }
 
@@ -122,14 +124,16 @@ func (g *GitOperator) Run(cmd *cobra.Command, args []string) error {
 	}
 
 	gitJobReconciler := &reconciler.GitJobReconciler{
-		Client:     mgr.GetClient(),
-		Scheme:     mgr.GetScheme(),
-		Image:      g.Image,
-		Scheduler:  sched,
-		Workers:    workers,
-		ShardID:    g.ShardID,
-		GitFetcher: &git.Fetch{},
-		Clock:      reconciler.RealClock{},
+		Client:          mgr.GetClient(),
+		Scheme:          mgr.GetScheme(),
+		Image:           g.Image,
+		Scheduler:       sched,
+		Workers:         workers,
+		ShardID:         g.ShardID,
+		JobNodeSelector: g.ShardNodeSelector,
+		GitFetcher:      &git.Fetch{},
+		Clock:           reconciler.RealClock{},
+		Recorder:        mgr.GetEventRecorderFor(fmt.Sprintf("gitjob-controller%s", g.ShardID)),
 	}
 
 	configReconciler := &fcreconciler.ConfigReconciler{
diff --git a/internal/cmd/controller/gitops/reconciler/gitjob_controller.go b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
index 1a3902cc..11cf6b28 100644
--- a/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob_controller.go
@@ -2,10 +2,12 @@ package reconciler
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"os"
 	"reflect"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/go-logr/logr"
@@ -21,6 +23,7 @@ import (
 
 	"github.com/rancher/wrangler/v3/pkg/name"
 
+	fleetevent "github.com/rancher/fleet/pkg/event"
 	batchv1 "k8s.io/api/batch/v1"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
@@ -28,6 +31,7 @@ import (
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/retry"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/builder"
@@ -74,13 +78,15 @@ func (RealClock) Since(t time.Time) time.Duration { return time.Since(t) }
 // CronJobReconciler reconciles a GitRepo resource to create a git cloning k8s job
 type GitJobReconciler struct {
 	client.Client
-	Scheme     *runtime.Scheme
-	Image      string
-	Scheduler  quartz.Scheduler
-	Workers    int
-	ShardID    string
-	GitFetcher GitFetcher
-	Clock      TimeGetter
+	Scheme          *runtime.Scheme
+	Image           string
+	Scheduler       quartz.Scheduler
+	Workers         int
+	ShardID         string
+	JobNodeSelector string
+	GitFetcher      GitFetcher
+	Clock           TimeGetter
+	Recorder        record.EventRecorder
 }
 
 func (r *GitJobReconciler) SetupWithManager(mgr ctrl.Manager) error {
@@ -141,8 +147,9 @@ func (r *GitJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 
 	// Restrictions / Overrides, gitrepo reconciler is responsible for setting error in status
 	oldStatus := gitrepo.Status.DeepCopy()
-	gitrepo, err := grutil.AuthorizeAndAssignDefaults(ctx, r.Client, gitrepo)
+	_, err := grutil.AuthorizeAndAssignDefaults(ctx, r.Client, gitrepo)
 	if err != nil {
+		r.Recorder.Event(gitrepo, fleetevent.Warning, "FailedToApplyRestrictions", err.Error())
 		return ctrl.Result{}, grutil.UpdateErrorStatus(ctx, r.Client, req.NamespacedName, *oldStatus, err)
 	}
 
@@ -175,7 +182,13 @@ func (r *GitJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		return ctrl.Result{}, nil
 	}
 
-	gitPollerWasExecuted, _ := r.checkPollingTask(ctx, gitrepo)
+	commitBefore := gitrepo.Status.Commit
+	gitPollerWasExecuted, err := r.checkPollingTask(ctx, gitrepo)
+	if err != nil {
+		r.Recorder.Event(gitrepo, fleetevent.Warning, "FailedToCheckCommit", err.Error())
+	} else if gitPollerWasExecuted && commitBefore != gitrepo.Status.Commit {
+		r.Recorder.Event(gitrepo, fleetevent.Normal, "GotNewCommit", gitrepo.Status.Commit)
+	}
 	// From this point onwards we have to take into account if the poller
 	// task was executed.
 	// If so, we need to return a Result with EnqueueAfter set.
@@ -186,7 +199,9 @@ func (r *GitJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		Name:      jobName(gitrepo),
 	}, &job)
 	if err != nil && !errors.IsNotFound(err) {
-		return reconcileResult(gitPollerWasExecuted, gitrepo), fmt.Errorf("error retrieving git job: %w", err)
+		err = fmt.Errorf("error retrieving git job: %w", err)
+		r.Recorder.Event(gitrepo, fleetevent.Warning, "FailedToGetGitJob", err.Error())
+		return reconcileResult(gitPollerWasExecuted, gitrepo), err
 	}
 
 	// Gitjob handling
@@ -197,10 +212,18 @@ func (r *GitJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 			if err == nil && commit != "" {
 				gitrepo.Status.Commit = commit
 			}
+			if err != nil {
+				r.Recorder.Event(gitrepo, fleetevent.Warning, "Failed", err.Error())
+			} else {
+				if gitPollerWasExecuted && commitBefore != gitrepo.Status.Commit {
+					r.Recorder.Event(gitrepo, fleetevent.Normal, "GotNewCommit", gitrepo.Status.Commit)
+				}
+			}
 		}
 
 		if gitrepo.Status.Commit != "" {
 			if err := r.validateExternalSecretExist(ctx, gitrepo); err != nil {
+				r.Recorder.Event(gitrepo, fleetevent.Warning, "FailedValidatingSecret", err.Error())
 				return reconcileResult(gitPollerWasExecuted, gitrepo), grutil.UpdateErrorStatus(ctx, r.Client, req.NamespacedName, gitrepo.Status, err)
 			}
 			logger.V(1).Info("Creating Git job resources")
@@ -213,6 +236,7 @@ func (r *GitJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 			if err := r.createJob(ctx, gitrepo); err != nil {
 				return reconcileResult(gitPollerWasExecuted, gitrepo), fmt.Errorf("error creating git job: %w", err)
 			}
+			r.Recorder.Event(gitrepo, fleetevent.Normal, "Created", "GitJob was created")
 		}
 	} else if gitrepo.Status.Commit != "" {
 		if err = r.deleteJobIfNeeded(ctx, gitrepo, &job); err != nil {
@@ -386,27 +410,48 @@ func (r *GitJobReconciler) createJob(ctx context.Context, gitRepo *v1alpha1.GitR
 
 func (r *GitJobReconciler) deleteJobIfNeeded(ctx context.Context, gitRepo *v1alpha1.GitRepo, job *batchv1.Job) error {
 	logger := log.FromContext(ctx)
+	jobDeleted := false
+	jobDeletedMessage := ""
 	// if force delete is set, delete the job to make sure a new job is created
 	if gitRepo.Spec.ForceSyncGeneration != gitRepo.Status.UpdateGeneration {
 		gitRepo.Status.UpdateGeneration = gitRepo.Spec.ForceSyncGeneration
-		logger.Info("job deletion triggered because of ForceUpdateGeneration")
+		jobDeletedMessage = "job deletion triggered because of ForceUpdateGeneration"
+		logger.Info(jobDeletedMessage)
 		if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !errors.IsNotFound(err) {
 			return err
 		}
+		jobDeleted = true
 	}
 
 	// k8s Jobs are immutable. Recreate the job if the GitRepo Spec has changed.
-	if gitRepo.Generation != gitRepo.Status.ObservedGeneration {
+	// Avoid deleting the job twice
+	if !jobDeleted && generationChanged(gitRepo) {
+		jobDeletedMessage = "job deletion triggered because of generation change"
 		gitRepo.Status.ObservedGeneration = gitRepo.Generation
-		logger.Info("job deletion triggered because of generation change")
+		logger.Info(jobDeletedMessage)
 		if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !errors.IsNotFound(err) {
 			return err
 		}
+		jobDeleted = true
+	}
+
+	if jobDeleted {
+		r.Recorder.Event(gitRepo, fleetevent.Normal, "JobDeleted", jobDeletedMessage)
 	}
 
 	return nil
 }
 
+func generationChanged(r *v1alpha1.GitRepo) bool {
+	// checks if generation changed.
+	// it ignores the case when Status.ObservedGeneration=0 because that's
+	// the initial value of a just created GitRepo and the initial value
+	// for Generation in k8s is 1.
+	// If we don't ignore we would be deleting the gitjob that was just created
+	// until later we reconcile ObservedGeneration with Generation
+	return (r.Generation != r.Status.ObservedGeneration) && r.Status.ObservedGeneration > 0
+}
+
 func jobName(obj *v1alpha1.GitRepo) string {
 	return name.SafeConcatName(obj.Name, name.Hex(obj.Spec.Repo+obj.Status.Commit, 5))
 }
@@ -551,6 +596,20 @@ func (r *GitJobReconciler) newJobSpec(ctx context.Context, gitrepo *v1alpha1.Git
 		volumeMounts = append(volumeMounts, volMnt)
 	}
 
+	shardID := gitrepo.Labels[sharding.ShardingRefLabel]
+
+	nodeSelector := map[string]string{"kubernetes.io/os": "linux"}
+	if shardID != "" && len(strings.TrimSpace(r.JobNodeSelector)) > 0 {
+		var shardNodeSelector map[string]string
+		if err := json.Unmarshal([]byte(r.JobNodeSelector), &shardNodeSelector); err != nil {
+			return nil, fmt.Errorf("could not decode shard node selector: %w", err)
+		}
+
+		for k, v := range shardNodeSelector {
+			nodeSelector[k] = v
+		}
+	}
+
 	saName := name.SafeConcatName("git", gitrepo.Name)
 	logger := log.FromContext(ctx)
 	args, envs := argsAndEnvs(gitrepo, logger.V(1).Enabled())
@@ -589,7 +648,7 @@ func (r *GitJobReconciler) newJobSpec(ctx context.Context, gitrepo *v1alpha1.Git
 						},
 					},
 				},
-				NodeSelector: map[string]string{"kubernetes.io/os": "linux"},
+				NodeSelector: nodeSelector,
 				Tolerations: []corev1.Toleration{{
 					Key:      "cattle.io/os",
 					Operator: "Equal",
diff --git a/internal/cmd/controller/target/target.go b/internal/cmd/controller/target/target.go
index bc320086..a24b7dec 100644
--- a/internal/cmd/controller/target/target.go
+++ b/internal/cmd/controller/target/target.go
@@ -58,22 +58,19 @@ func (t *Target) BundleDeployment() *fleet.BundleDeployment {
 		Spec: t.Deployment.Spec,
 	}
 	bd.Spec.Paused = t.IsPaused()
+
 	for _, bundleTarget := range t.Bundle.Spec.Targets {
-		if bundleTarget.NamespaceLabels != nil {
-			for key, value := range *bundleTarget.NamespaceLabels {
-				(*bd.Spec.Options.NamespaceLabels)[key] = value
-				(*bd.Spec.StagedOptions.NamespaceLabels)[key] = value
-			}
+		for key, value := range bundleTarget.NamespaceLabels {
+			bd.Spec.Options.NamespaceLabels[key] = value
+			bd.Spec.StagedOptions.NamespaceLabels[key] = value
 		}
-	}
-	for _, bundleTargets := range t.Bundle.Spec.Targets {
-		if bundleTargets.NamespaceAnnotations != nil {
-			for key, value := range *bundleTargets.NamespaceAnnotations {
-				(*bd.Spec.Options.NamespaceAnnotations)[key] = value
-				(*bd.Spec.StagedOptions.NamespaceAnnotations)[key] = value
-			}
+
+		for key, value := range bundleTarget.NamespaceAnnotations {
+			bd.Spec.Options.NamespaceAnnotations[key] = value
+			bd.Spec.StagedOptions.NamespaceAnnotations[key] = value
 		}
 	}
+
 	bd.Spec.DependsOn = t.Bundle.Spec.DependsOn
 	bd.Spec.CorrectDrift = t.Options.CorrectDrift
 	return bd
diff --git a/internal/config/config.go b/internal/config/config.go
index d7f92fe9..98fd05e5 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -113,6 +113,9 @@ type Config struct {
 	// canceling the request.  Used to retrieve the latest commit of configured
 	// git repositories.
 	GitClientTimeout metav1.Duration `json:"gitClientTimeout,omitempty"`
+
+	// GarbageCollectionInterval determines how often agents clean up obsolete Helm releases.
+	GarbageCollectionInterval metav1.Duration `json:"garbageCollectionInterval,omitempty"`
 }
 
 type Bootstrap struct {
diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go
index f7ee1c32..e7c58886 100644
--- a/internal/manifest/manifest.go
+++ b/internal/manifest/manifest.go
@@ -109,10 +109,10 @@ func (m *Manifest) ID() (string, error) {
 	if err != nil {
 		return "", err
 	}
-	return toSHA256ID(shasum), nil
+	return ToSHA256ID(shasum), nil
 }
 
-// toSHA256ID generates a valid Kubernetes name (max length of 64) from a provided SHA256 sum
-func toSHA256ID(shasum string) string {
+// ToSHA256ID generates a valid Kubernetes name (max length of 64) from a provided SHA256 sum
+func ToSHA256ID(shasum string) string {
 	return ("s-" + shasum)[:63]
 }
diff --git a/internal/manifest/store.go b/internal/manifest/store.go
index f5a29996..77f36ef9 100644
--- a/internal/manifest/store.go
+++ b/internal/manifest/store.go
@@ -14,12 +14,12 @@ import (
 
 func NewStore(client client.Client) *ContentStore {
 	return &ContentStore{
-		client: client,
+		Client: client,
 	}
 }
 
 type ContentStore struct {
-	client client.Client
+	Client client.Client
 }
 
 // Store stores the manifest as a content resource.
@@ -30,7 +30,7 @@ func (c *ContentStore) Store(ctx context.Context, manifest *Manifest) error {
 		return err
 	}
 
-	if err := c.client.Get(ctx, types.NamespacedName{Name: id}, &fleet.Content{}); err != nil && !apierrors.IsNotFound(err) {
+	if err := c.Client.Get(ctx, types.NamespacedName{Name: id}, &fleet.Content{}); err != nil && !apierrors.IsNotFound(err) {
 		return err
 	} else if err == nil {
 		return nil
@@ -55,7 +55,7 @@ func (c *ContentStore) createContents(ctx context.Context, id string, manifest *
 		return err
 	}
 
-	err = c.client.Create(ctx, &fleet.Content{
+	err = c.Client.Create(ctx, &fleet.Content{
 		ObjectMeta: metav1.ObjectMeta{
 			Name: id,
 		},
diff --git a/internal/mocks/eventrecorder_mock.go b/internal/mocks/eventrecorder_mock.go
new file mode 100644
index 00000000..edf5528b
--- /dev/null
+++ b/internal/mocks/eventrecorder_mock.go
@@ -0,0 +1,81 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: k8s.io/client-go/tools/record (interfaces: EventRecorder)
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	reflect "reflect"
+
+	gomock "go.uber.org/mock/gomock"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// MockEventRecorder is a mock of EventRecorder interface.
+type MockEventRecorder struct {
+	ctrl     *gomock.Controller
+	recorder *MockEventRecorderMockRecorder
+}
+
+// MockEventRecorderMockRecorder is the mock recorder for MockEventRecorder.
+type MockEventRecorderMockRecorder struct {
+	mock *MockEventRecorder
+}
+
+// NewMockEventRecorder creates a new mock instance.
+func NewMockEventRecorder(ctrl *gomock.Controller) *MockEventRecorder {
+	mock := &MockEventRecorder{ctrl: ctrl}
+	mock.recorder = &MockEventRecorderMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockEventRecorder) EXPECT() *MockEventRecorderMockRecorder {
+	return m.recorder
+}
+
+// AnnotatedEventf mocks base method.
+func (m *MockEventRecorder) AnnotatedEventf(arg0 runtime.Object, arg1 map[string]string, arg2, arg3, arg4 string, arg5 ...interface{}) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{arg0, arg1, arg2, arg3, arg4}
+	for _, a := range arg5 {
+		varargs = append(varargs, a)
+	}
+	m.ctrl.Call(m, "AnnotatedEventf", varargs...)
+}
+
+// AnnotatedEventf indicates an expected call of AnnotatedEventf.
+func (mr *MockEventRecorderMockRecorder) AnnotatedEventf(arg0, arg1, arg2, arg3, arg4 interface{}, arg5 ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{arg0, arg1, arg2, arg3, arg4}, arg5...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AnnotatedEventf", reflect.TypeOf((*MockEventRecorder)(nil).AnnotatedEventf), varargs...)
+}
+
+// Event mocks base method.
+func (m *MockEventRecorder) Event(arg0 runtime.Object, arg1, arg2, arg3 string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "Event", arg0, arg1, arg2, arg3)
+}
+
+// Event indicates an expected call of Event.
+func (mr *MockEventRecorderMockRecorder) Event(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Event", reflect.TypeOf((*MockEventRecorder)(nil).Event), arg0, arg1, arg2, arg3)
+}
+
+// Eventf mocks base method.
+func (m *MockEventRecorder) Eventf(arg0 runtime.Object, arg1, arg2, arg3 string, arg4 ...interface{}) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{arg0, arg1, arg2, arg3}
+	for _, a := range arg4 {
+		varargs = append(varargs, a)
+	}
+	m.ctrl.Call(m, "Eventf", varargs...)
+}
+
+// Eventf indicates an expected call of Eventf.
+func (mr *MockEventRecorderMockRecorder) Eventf(arg0, arg1, arg2, arg3 interface{}, arg4 ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{arg0, arg1, arg2, arg3}, arg4...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Eventf", reflect.TypeOf((*MockEventRecorder)(nil).Eventf), varargs...)
+}
diff --git a/internal/mocks/helm_deployer_mock.go b/internal/mocks/helm_deployer_mock.go
new file mode 100644
index 00000000..d27da382
--- /dev/null
+++ b/internal/mocks/helm_deployer_mock.go
@@ -0,0 +1,99 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/rancher/fleet/internal/cmd/agent/deployer/cleanup (interfaces: HelmDeployer)
+//
+// Generated by this command:
+//
+//	mockgen --build_flags=--mod=mod -destination=../../../../mocks/helm_deployer_mock.go -package=mocks github.com/rancher/fleet/internal/cmd/agent/deployer/cleanup HelmDeployer
+//
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	context "context"
+	reflect "reflect"
+
+	helmdeployer "github.com/rancher/fleet/internal/helmdeployer"
+	gomock "go.uber.org/mock/gomock"
+	action "helm.sh/helm/v3/pkg/action"
+)
+
+// MockHelmDeployer is a mock of HelmDeployer interface.
+type MockHelmDeployer struct {
+	ctrl     *gomock.Controller
+	recorder *MockHelmDeployerMockRecorder
+}
+
+// MockHelmDeployerMockRecorder is the mock recorder for MockHelmDeployer.
+type MockHelmDeployerMockRecorder struct {
+	mock *MockHelmDeployer
+}
+
+// NewMockHelmDeployer creates a new mock instance.
+func NewMockHelmDeployer(ctrl *gomock.Controller) *MockHelmDeployer {
+	mock := &MockHelmDeployer{ctrl: ctrl}
+	mock.recorder = &MockHelmDeployerMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockHelmDeployer) EXPECT() *MockHelmDeployerMockRecorder {
+	return m.recorder
+}
+
+// Delete mocks base method.
+func (m *MockHelmDeployer) Delete(arg0 context.Context, arg1 string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Delete", arg0, arg1)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Delete indicates an expected call of Delete.
+func (mr *MockHelmDeployerMockRecorder) Delete(arg0, arg1 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockHelmDeployer)(nil).Delete), arg0, arg1)
+}
+
+// DeleteRelease mocks base method.
+func (m *MockHelmDeployer) DeleteRelease(arg0 context.Context, arg1 helmdeployer.DeployedBundle) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DeleteRelease", arg0, arg1)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// DeleteRelease indicates an expected call of DeleteRelease.
+func (mr *MockHelmDeployerMockRecorder) DeleteRelease(arg0, arg1 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRelease", reflect.TypeOf((*MockHelmDeployer)(nil).DeleteRelease), arg0, arg1)
+}
+
+// ListDeployments mocks base method.
+func (m *MockHelmDeployer) ListDeployments(arg0 helmdeployer.ListAction) ([]helmdeployer.DeployedBundle, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListDeployments", arg0)
+	ret0, _ := ret[0].([]helmdeployer.DeployedBundle)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ListDeployments indicates an expected call of ListDeployments.
+func (mr *MockHelmDeployerMockRecorder) ListDeployments(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListDeployments", reflect.TypeOf((*MockHelmDeployer)(nil).ListDeployments), arg0)
+}
+
+// NewListAction mocks base method.
+func (m *MockHelmDeployer) NewListAction() *action.List {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "NewListAction")
+	ret0, _ := ret[0].(*action.List)
+	return ret0
+}
+
+// NewListAction indicates an expected call of NewListAction.
+func (mr *MockHelmDeployerMockRecorder) NewListAction() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListAction", reflect.TypeOf((*MockHelmDeployer)(nil).NewListAction))
+}
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
index a3431fbd..e010f293 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go
@@ -229,10 +229,10 @@ type BundleTarget struct {
 	DoNotDeploy bool `json:"doNotDeploy,omitempty"`
 	// NamespaceLabels are labels that will be appended to the namespace created by Fleet.
 	// +nullable
-	NamespaceLabels *map[string]string `json:"namespaceLabels,omitempty"`
+	NamespaceLabels map[string]string `json:"namespaceLabels,omitempty"`
 	// NamespaceAnnotations are annotations that will be appended to the namespace created by Fleet.
 	// +nullable
-	NamespaceAnnotations *map[string]string `json:"namespaceAnnotations,omitempty"`
+	NamespaceAnnotations map[string]string `json:"namespaceAnnotations,omitempty"`
 }
 
 // BundleSummary contains the number of bundle deployments in each state and a
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go
index 31a3a7e2..4062a2c9 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundledeployment_types.go
@@ -103,11 +103,11 @@ type BundleDeploymentOptions struct {
 
 	// NamespaceLabels are labels that will be appended to the namespace created by Fleet.
 	// +nullable
-	NamespaceLabels *map[string]string `json:"namespaceLabels,omitempty"`
+	NamespaceLabels map[string]string `json:"namespaceLabels,omitempty"`
 
 	// NamespaceAnnotations are annotations that will be appended to the namespace created by Fleet.
 	// +nullable
-	NamespaceAnnotations *map[string]string `json:"namespaceAnnotations,omitempty"`
+	NamespaceAnnotations map[string]string `json:"namespaceAnnotations,omitempty"`
 
 	// DeleteCRDResources deletes CRDs. Warning! this will also delete all your Custom Resources.
 	DeleteCRDResources bool `json:"deleteCRDResources,omitempty"`
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/cluster_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/cluster_types.go
index dc1ed40c..aa428459 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/cluster_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/cluster_types.go
@@ -214,6 +214,9 @@ type ClusterStatus struct {
 	Display ClusterDisplay `json:"display,omitempty"`
 	// AgentStatus contains information about the agent.
 	Agent AgentStatus `json:"agent,omitempty"`
+
+	// GarbageCollectionInterval determines how often agents clean up obsolete Helm releases.
+	GarbageCollectionInterval *metav1.Duration `json:"garbageCollectionInterval,omitempty"`
 }
 
 type ClusterDisplay struct {
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go
index e64aa508..d2a814dc 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go
@@ -191,24 +191,16 @@ func (in *BundleDeploymentOptions) DeepCopyInto(out *BundleDeploymentOptions) {
 	}
 	if in.NamespaceLabels != nil {
 		in, out := &in.NamespaceLabels, &out.NamespaceLabels
-		*out = new(map[string]string)
-		if **in != nil {
-			in, out := *in, *out
-			*out = make(map[string]string, len(*in))
-			for key, val := range *in {
-				(*out)[key] = val
-			}
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
 		}
 	}
 	if in.NamespaceAnnotations != nil {
 		in, out := &in.NamespaceAnnotations, &out.NamespaceAnnotations
-		*out = new(map[string]string)
-		if **in != nil {
-			in, out := *in, *out
-			*out = make(map[string]string, len(*in))
-			for key, val := range *in {
-				(*out)[key] = val
-			}
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
 		}
 	}
 }
@@ -581,24 +573,16 @@ func (in *BundleTarget) DeepCopyInto(out *BundleTarget) {
 	}
 	if in.NamespaceLabels != nil {
 		in, out := &in.NamespaceLabels, &out.NamespaceLabels
-		*out = new(map[string]string)
-		if **in != nil {
-			in, out := *in, *out
-			*out = make(map[string]string, len(*in))
-			for key, val := range *in {
-				(*out)[key] = val
-			}
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
 		}
 	}
 	if in.NamespaceAnnotations != nil {
 		in, out := &in.NamespaceAnnotations, &out.NamespaceAnnotations
-		*out = new(map[string]string)
-		if **in != nil {
-			in, out := *in, *out
-			*out = make(map[string]string, len(*in))
-			for key, val := range *in {
-				(*out)[key] = val
-			}
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
 		}
 	}
 }
@@ -1093,6 +1077,11 @@ func (in *ClusterStatus) DeepCopyInto(out *ClusterStatus) {
 	}
 	out.Display = in.Display
 	in.Agent.DeepCopyInto(&out.Agent)
+	if in.GarbageCollectionInterval != nil {
+		in, out := &in.GarbageCollectionInterval, &out.GarbageCollectionInterval
+		*out = new(v1.Duration)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterStatus.
diff --git a/pkg/apis/go.mod b/pkg/apis/go.mod
index 4acaaa7d..e556dd9e 100644
--- a/pkg/apis/go.mod
+++ b/pkg/apis/go.mod
@@ -2,7 +2,7 @@ module github.com/rancher/fleet/pkg/apis
 
 go 1.22.0
 
-toolchain go1.22.5
+toolchain go1.22.6
 
 require (
 	github.com/rancher/wrangler/v3 v3.0.0
diff --git a/pkg/event/event.go b/pkg/event/event.go
new file mode 100644
index 00000000..ca955ab0
--- /dev/null
+++ b/pkg/event/event.go
@@ -0,0 +1,6 @@
+package event
+
+const (
+	Normal  = "Normal"
+	Warning = "Warning"
+)
diff --git a/pkg/webhook/azuredevops/webhook.go b/pkg/webhook/azuredevops/webhook.go
deleted file mode 100644
index f0a3c309..00000000
--- a/pkg/webhook/azuredevops/webhook.go
+++ /dev/null
@@ -1,111 +0,0 @@
-// File copied from https://github.com/go-playground/webhooks/blob/master/azuredevops/azuredevops.go
-// TODO Basic Auth is added here since it's not available upstream.
-// Remove this file once https://github.com/go-playground/webhooks/pull/191 is merged.
-
-package azuredevops
-
-import (
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-
-	"github.com/go-playground/webhooks/v6/azuredevops"
-)
-
-// parse errors
-var (
-	ErrInvalidHTTPMethod           = errors.New("invalid HTTP Method")
-	ErrParsingPayload              = errors.New("error parsing payload")
-	ErrBasicAuthVerificationFailed = errors.New("basic auth verification failed")
-)
-
-// Option is a configuration option for the webhook
-type Option func(*Webhook) error
-
-// Options is a namespace var for configuration options
-var Options = WebhookOptions{}
-
-// WebhookOptions is a namespace for configuration option methods
-type WebhookOptions struct{}
-
-// BasicAuth verifies payload using basic auth
-func (WebhookOptions) BasicAuth(username, password string) Option {
-	return func(hook *Webhook) error {
-		hook.username = username
-		hook.password = password
-		return nil
-	}
-}
-
-// Webhook instance contains all methods needed to process events
-type Webhook struct {
-	username string
-	password string
-}
-
-// New creates and returns a WebHook instance
-func New(options ...Option) (*Webhook, error) {
-	hook := new(Webhook)
-	for _, opt := range options {
-		if err := opt(hook); err != nil {
-			return nil, errors.New("Error applying Option")
-		}
-	}
-	return hook, nil
-}
-
-// Parse verifies and parses the events specified and returns the payload object or an error
-func (hook Webhook) Parse(r *http.Request, events ...azuredevops.Event) (interface{}, error) {
-	defer func() {
-		_, _ = io.Copy(io.Discard, r.Body)
-		_ = r.Body.Close()
-	}()
-
-	if !hook.verifyBasicAuth(r) {
-		return nil, ErrBasicAuthVerificationFailed
-	}
-
-	if r.Method != http.MethodPost {
-		return nil, ErrInvalidHTTPMethod
-	}
-
-	payload, err := io.ReadAll(r.Body)
-	if err != nil || len(payload) == 0 {
-		return nil, ErrParsingPayload
-	}
-
-	var pl azuredevops.BasicEvent
-	err = json.Unmarshal([]byte(payload), &pl)
-	if err != nil {
-		return nil, ErrParsingPayload
-	}
-
-	switch pl.EventType {
-	case azuredevops.GitPushEventType:
-		var fpl azuredevops.GitPushEvent
-		err = json.Unmarshal([]byte(payload), &fpl)
-		return fpl, err
-	case azuredevops.GitPullRequestCreatedEventType, azuredevops.GitPullRequestMergedEventType, azuredevops.GitPullRequestUpdatedEventType:
-		var fpl azuredevops.GitPullRequestEvent
-		err = json.Unmarshal([]byte(payload), &fpl)
-		return fpl, err
-	case azuredevops.BuildCompleteEventType:
-		var fpl azuredevops.BuildCompleteEvent
-		err = json.Unmarshal([]byte(payload), &fpl)
-		return fpl, err
-	default:
-		return nil, fmt.Errorf("unknown event %s", pl.EventType)
-	}
-}
-
-func (hook Webhook) verifyBasicAuth(r *http.Request) bool {
-	// skip validation if username or password was not provided
-	if hook.username == "" && hook.password == "" {
-		return true
-	}
-	username, password, ok := r.BasicAuth()
-
-	return ok && username == hook.username && password == hook.password
-}
diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go
index 71d4e57e..a7e94c58 100644
--- a/pkg/webhook/webhook.go
+++ b/pkg/webhook/webhook.go
@@ -10,7 +10,7 @@ import (
 	"time"
 
 	"github.com/go-logr/logr"
-	goPlaygroundAzuredevops "github.com/go-playground/webhooks/v6/azuredevops"
+	"github.com/go-playground/webhooks/v6/azuredevops"
 	gogsclient "github.com/gogits/go-gogs-client"
 	"github.com/gorilla/mux"
 	"gopkg.in/go-playground/webhooks.v5/bitbucket"
@@ -20,7 +20,6 @@ import (
 	"gopkg.in/go-playground/webhooks.v5/gogs"
 
 	v1alpha1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
-	"github.com/rancher/fleet/pkg/webhook/azuredevops"
 
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -171,7 +170,7 @@ func (w *Webhook) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
 	case r.Header.Get("X-Event-Key") != "":
 		payload, err = w.bitbucketServer.Parse(r, bitbucketserver.RepositoryReferenceChangedEvent)
 	case r.Header.Get("X-Vss-Activityid") != "" || r.Header.Get("X-Vss-Subscriptionid") != "":
-		payload, err = w.azureDevops.Parse(r, goPlaygroundAzuredevops.GitPushEventType)
+		payload, err = w.azureDevops.Parse(r, azuredevops.GitPushEventType)
 	default:
 		w.log.V(1).Info("Ignoring unknown webhook event")
 		return
@@ -366,7 +365,7 @@ func parsePayload(payload interface{}) (revision, branch, tag string, repoURLs [
 		repoURLs = append(repoURLs, t.Repo.HTMLURL)
 		branch, tag = getBranchTagFromRef(t.Ref)
 		revision = t.After
-	case goPlaygroundAzuredevops.GitPushEvent:
+	case azuredevops.GitPushEvent:
 		repoURLs = append(repoURLs, t.Resource.Repository.RemoteURL)
 		for _, refUpdate := range t.Resource.RefUpdates {
 			branch, tag = getBranchTagFromRef(refUpdate.Name)


Test patch:
diff --git a/e2e/assets/gitrepo/gitrepo.yaml b/e2e/assets/gitrepo/gitrepo.yaml
index 8d07f5b7..00f74ed4 100644
--- a/e2e/assets/gitrepo/gitrepo.yaml
+++ b/e2e/assets/gitrepo/gitrepo.yaml
@@ -6,6 +6,8 @@ spec:
   repo: {{.Repo}}
   branch: {{.Branch}}
   pollingInterval: {{.PollingInterval}}
+  {{- if .TargetNamespace }}
   targetNamespace: {{.TargetNamespace}}
+  {{- end }}
   paths:
   - examples
diff --git a/e2e/assets/helm/repo/with-release-name/config-chart/Chart.yaml b/e2e/assets/helm/repo/with-release-name/config-chart/Chart.yaml
new file mode 100644
index 00000000..7ba744f9
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/config-chart/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: config-chart
+description: A test chart that verifies its config
+type: application
+version: 0.1.0
+appVersion: "1.16.0"
diff --git a/e2e/assets/helm/repo/with-release-name/config-chart/templates/configmap.yaml b/e2e/assets/helm/repo/with-release-name/config-chart/templates/configmap.yaml
new file mode 100644
index 00000000..340a61bf
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/config-chart/templates/configmap.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: test-simple-chart-config
+data:
+  test: "value123"
+  name: {{ .Values.name }}
diff --git a/e2e/assets/helm/repo/with-release-name/config-chart/values.yaml b/e2e/assets/helm/repo/with-release-name/config-chart/values.yaml
new file mode 100644
index 00000000..3266b4da
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/config-chart/values.yaml
@@ -0,0 +1,1 @@
+name: default-name
diff --git a/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/Chart.yaml b/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/Chart.yaml
new file mode 100644
index 00000000..b6f09293
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: crds-chart
+description: A test chart that only contains CRDs
+type: application
+version: 0.1.0
+appVersion: "1.16.0"
diff --git a/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/templates/crds.yaml b/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/templates/crds.yaml
new file mode 100644
index 00000000..4c87bb28
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/templates/crds.yaml
@@ -0,0 +1,41 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.15.0
+  name: foobars.crd.test
+spec:
+  group: crd.test
+  names:
+    kind: Foobar
+    listKind: FoobarList
+    plural: foobars
+    singular: foobar
+  scope: Namespaced
+  versions:
+    - name: v1alpha1
+      schema:
+        openAPIV3Schema:
+          description: 'Foobar is a dummy resource.'
+          properties:
+            apiVersion:
+              description: 'APIVersion is always necessary.'
+              type: string
+            foo:
+              description: 'Foo is important.'
+              nullable: true
+              type: string
+            bar:
+              description: 'Bar does not walk into a Foo. A Foo walks into a bar.'
+              nullable: true
+              type: string
+            kind:
+              description: 'Kindness is everything.'
+              type: string
+            metadata:
+              type: object
+          type: object
+      served: true
+      storage: true
+      subresources:
+        status: {}
diff --git a/e2e/assets/helm/repo/with-release-name/crds-only/fleet.yaml b/e2e/assets/helm/repo/with-release-name/crds-only/fleet.yaml
new file mode 100644
index 00000000..7de353d4
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/crds-only/fleet.yaml
@@ -0,0 +1,5 @@
+helm:
+  chart: crds-chart
+  values:
+    name: example-value
+  releaseName: test-config-release
diff --git a/e2e/assets/helm/repo/with-release-name/fleet.yaml b/e2e/assets/helm/repo/with-release-name/fleet.yaml
new file mode 100644
index 00000000..b1464bd8
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/fleet.yaml
@@ -0,0 +1,5 @@
+helm:
+  chart: config-chart
+  values:
+    name: example-value
+  releaseName: test-config-release
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/Chart.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/Chart.yaml
new file mode 100644
index 00000000..7ba744f9
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: config-chart
+description: A test chart that verifies its config
+type: application
+version: 0.1.0
+appVersion: "1.16.0"
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/templates/configmap.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/templates/configmap.yaml
new file mode 100644
index 00000000..340a61bf
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/templates/configmap.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: test-simple-chart-config
+data:
+  test: "value123"
+  name: {{ .Values.name }}
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/values.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/values.yaml
new file mode 100644
index 00000000..3266b4da
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/values.yaml
@@ -0,0 +1,1 @@
+name: default-name
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/fleet.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/fleet.yaml
new file mode 100644
index 00000000..b1464bd8
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/no-crds/fleet.yaml
@@ -0,0 +1,5 @@
+helm:
+  chart: config-chart
+  values:
+    name: example-value
+  releaseName: test-config-release
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/values.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/values.yaml
new file mode 100644
index 00000000..b0a9f2b3
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/no-crds/values.yaml
@@ -0,0 +1,1 @@
+url: global.fleet.clusterLabels.name
diff --git a/e2e/assets/helm/repo/with-release-name/values.yaml b/e2e/assets/helm/repo/with-release-name/values.yaml
new file mode 100644
index 00000000..b0a9f2b3
--- /dev/null
+++ b/e2e/assets/helm/repo/with-release-name/values.yaml
@@ -0,0 +1,1 @@
+url: global.fleet.clusterLabels.name
diff --git a/e2e/assets/single-cluster/namespaceLabels_targetCustomization.yaml b/e2e/assets/single-cluster/namespaceLabels_targetCustomization.yaml
index f53ed41b..337746ae 100644
--- a/e2e/assets/single-cluster/namespaceLabels_targetCustomization.yaml
+++ b/e2e/assets/single-cluster/namespaceLabels_targetCustomization.yaml
@@ -4,7 +4,7 @@ metadata:
   name: test
   namespace: fleet-local
 spec:
-  repo: https://github.com/Tommy12789/fleet-examples-tommy
-  branch: test
+  repo: https://github.com/rancher/fleet-test-data
+  branch: master
   paths:
-    - namespaceLabels_targetCustomization
+    - target-customization-namespace-labels
diff --git a/e2e/single-cluster/release_cleanup_test.go b/e2e/single-cluster/release_cleanup_test.go
new file mode 100644
index 00000000..e7b6e228
--- /dev/null
+++ b/e2e/single-cluster/release_cleanup_test.go
@@ -0,0 +1,223 @@
+package singlecluster_test
+
+import (
+	"fmt"
+	"math/rand"
+	"os"
+	"os/exec"
+	"path"
+	"strings"
+
+	"github.com/go-git/go-git/v5"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/rancher/fleet/e2e/testenv"
+	"github.com/rancher/fleet/e2e/testenv/githelper"
+	"github.com/rancher/fleet/e2e/testenv/kubectl"
+)
+
+var _ = Describe("Monitoring Helm releases along GitRepo/bundle namespace updates", Ordered, func() {
+	var (
+		k            kubectl.Command
+		r            = rand.New(rand.NewSource(GinkgoRandomSeed()))
+		oldNamespace string
+		newNamespace string
+		gitrepoName  string
+		path         string
+	)
+
+	BeforeAll(func() {
+		k = env.Kubectl.Namespace(env.Namespace)
+		oldNamespace = testenv.NewNamespaceName("target", r)
+		newNamespace = testenv.NewNamespaceName("target", r)
+		gitrepoName = "releases-cleanup"
+		path = "simple-chart"
+
+		err := testenv.CreateGitRepo(
+			k,
+			oldNamespace,
+			gitrepoName,
+			"master",
+			"",
+			path,
+		)
+		Expect(err).ToNot(HaveOccurred())
+
+		// Check for existence of Helm release as the only release in the target namespace
+		checkRelease(oldNamespace, fmt.Sprintf("%s-%s", gitrepoName, path))
+	})
+
+	AfterAll(func() {
+		out, err := k.Delete("gitrepo", gitrepoName)
+		Expect(err).ToNot(HaveOccurred(), out)
+
+		out, err = k.Delete("ns", oldNamespace, "--wait=false")
+		Expect(err).ToNot(HaveOccurred(), out)
+
+		out, err = k.Delete("ns", newNamespace, "--wait=false")
+		Expect(err).ToNot(HaveOccurred(), out)
+	})
+
+	When("updating a bundle's namespace", func() {
+		It("creates a new release in the new namespace", func() {
+			out, err := k.Patch(
+				"gitrepo",
+				gitrepoName,
+				"--type=merge",
+				"-p",
+				fmt.Sprintf(`{"spec":{"targetNamespace":"%s"}}`, newNamespace),
+			)
+			Expect(err).ToNot(HaveOccurred(), out)
+
+			checkRelease(newNamespace, fmt.Sprintf("%s-%s", gitrepoName, path))
+		})
+
+		It("deletes the old release in the previous namespace", func() {
+			Eventually(func(g Gomega) {
+				cmd := exec.Command("helm", "list", "-q", "-n", oldNamespace)
+				out, err := cmd.CombinedOutput()
+				g.Expect(err).ToNot(HaveOccurred())
+				g.Expect(string(out)).To(BeEmpty())
+			}).Should(Succeed())
+		})
+	})
+})
+
+var _ = Describe("Monitoring Helm releases along GitRepo/bundle release name updates", Ordered, Label("infra-setup"), func() {
+	var (
+		tmpDir           string
+		clonedir         string
+		k                kubectl.Command
+		gh               *githelper.Git
+		clone            *git.Repository
+		repoName         string
+		inClusterRepoURL string
+		gitrepoName      string
+		r                = rand.New(rand.NewSource(GinkgoRandomSeed()))
+		targetNamespace  string
+		oldReleaseName   string
+		newReleaseName   string
+		assetPath        string
+	)
+
+	BeforeEach(func() {
+		k = env.Kubectl.Namespace(env.Namespace)
+		oldReleaseName = "test-config-release"
+		newReleaseName = "new-test-config-release"
+		repoName = "repo"
+
+		DeferCleanup(func() {
+			out, err := k.Delete("gitrepo", gitrepoName)
+			Expect(err).ToNot(HaveOccurred(), out)
+
+			if targetNamespace != "" {
+				out, err = k.Delete("ns", targetNamespace, "--wait=false")
+				Expect(err).ToNot(HaveOccurred(), out)
+			}
+
+			_ = os.RemoveAll(tmpDir)
+		})
+	})
+
+	JustBeforeEach(func() {
+		// 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)
+
+		gitrepoName = testenv.RandomFilename("releases-test", r)
+
+		err = testenv.ApplyTemplate(k, testenv.AssetPath("gitrepo/gitrepo.yaml"), struct {
+			Name            string
+			Repo            string
+			Branch          string
+			PollingInterval string
+			TargetNamespace string
+		}{
+			gitrepoName,
+			inClusterRepoURL,
+			gh.Branch,
+			"15s",           // default
+			targetNamespace, // to avoid conflicts with other tests
+		})
+		Expect(err).ToNot(HaveOccurred())
+
+		clone, err = gh.Create(clonedir, testenv.AssetPath(assetPath), "examples")
+		Expect(err).ToNot(HaveOccurred())
+
+		// Check for existence of Helm release with old name in the target namespace
+		checkRelease(targetNamespace, oldReleaseName)
+	})
+
+	When("updating the release name of a bundle containing upstream Kubernetes resources", func() {
+		BeforeEach(func() {
+			assetPath = "helm/repo/with-release-name/no-crds"
+			targetNamespace = testenv.NewNamespaceName("target", r)
+		})
+		It("replaces the release", func() {
+			By("updating the release name in fleet.yaml")
+			// Not adding `takeOwnership: true` leads to errors from Helm checking its own annotations and failing to
+			// deploy the new release because the config map already exists and belongs to the old one.
+			replace(
+				path.Join(clonedir, "examples", "fleet.yaml"),
+				oldReleaseName,
+				fmt.Sprintf("%s\n  takeOwnership: true", newReleaseName),
+			)
+
+			_, err := gh.Update(clone)
+			Expect(err).ToNot(HaveOccurred())
+
+			By("creating a release with the new name")
+			checkRelease(targetNamespace, newReleaseName)
+		})
+	})
+
+	When("updating the release name of a bundle containing only CRDs", func() {
+		BeforeEach(func() {
+			assetPath = "helm/repo/with-release-name/crds-only"
+			targetNamespace = ""
+			DeferCleanup(func() {
+				out, err := k.Delete("crd", "foobars.crd.test")
+				Expect(err).ToNot(HaveOccurred(), out)
+			})
+		})
+		It("replaces the release", func() {
+			By("updating the release name in fleet.yaml")
+			replace(
+				path.Join(clonedir, "examples", "fleet.yaml"),
+				oldReleaseName,
+				fmt.Sprintf("%s\n  takeOwnership: true", newReleaseName),
+			)
+
+			_, err := gh.Update(clone)
+			Expect(err).ToNot(HaveOccurred())
+
+			By("creating a release with the new name")
+			checkRelease("default", newReleaseName)
+		})
+	})
+})
+
+// checkRelease validates that namespace eventually contains a single release named releaseName.
+func checkRelease(namespace, releaseName string) {
+	var releases []string
+	Eventually(func(g Gomega) {
+		cmd := exec.Command("helm", "list", "-q", "-n", namespace)
+		out, err := cmd.CombinedOutput()
+		g.Expect(err).ToNot(HaveOccurred())
+		g.Expect(string(out)).ToNot(BeEmpty())
+
+		releases = strings.Split(strings.TrimSpace(string(out)), "\n")
+
+		g.Expect(len(releases)).To(Equal(1), strings.Join(releases, ","))
+		g.Expect(releases[0]).To(Equal(releaseName))
+	}).Should(Succeed())
+}
diff --git a/e2e/single-cluster/sharding_test.go b/e2e/single-cluster/sharding_test.go
index 9b4cf05b..1fb60354 100644
--- a/e2e/single-cluster/sharding_test.go
+++ b/e2e/single-cluster/sharding_test.go
@@ -3,6 +3,7 @@ package singlecluster_test
 import (
 	"fmt"
 	"math/rand"
+	"strings"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -76,6 +77,38 @@ var _ = Describe("Filtering events by shard", Label("sharding"), Ordered, func()
 					return out
 				}).Should(ContainSubstring("test-simple-chart-config"))
 
+				By("checking the gitjob pod has the same nodeSelector as the sharded controller")
+				Eventually(func(g Gomega) {
+					shardNodeSelector, err := k.Namespace("cattle-fleet-system").Get(
+						"pods",
+						"-o=jsonpath={.items[0].spec.nodeSelector}",
+						"-l",
+						"app=fleet-controller",
+						"-l",
+						fmt.Sprintf("fleet.cattle.io/shard-id=%s", shard),
+					)
+					g.Expect(err).ToNot(HaveOccurred())
+
+					pods, _ := k.Namespace("fleet-local").Get(
+						"pods",
+						"-o",
+						`jsonpath={range .items[*]}{.metadata.name}{"\t"}{.spec.nodeSelector}{"\n"}{end}`,
+					)
+
+					var podNodeSelector string
+					for _, pod := range strings.Split(pods, "\n") {
+						fields := strings.Split(pod, "\t")
+						podName := fields[0]
+						if strings.HasPrefix(podName, "sharding-test") {
+							podNodeSelector = fields[1]
+							break
+						}
+					}
+
+					g.Expect(podNodeSelector).ToNot(BeEmpty())
+					g.Expect(podNodeSelector).To(Equal(shardNodeSelector))
+				}).Should(Succeed())
+
 				for _, s := range shards {
 					Eventually(func(g Gomega) {
 						logs, err := k.Namespace("cattle-fleet-system").Logs(
diff --git a/e2e/single-cluster/targetCustomization_test.go b/e2e/single-cluster/targetCustomization_test.go
index 80c69e21..651563e0 100644
--- a/e2e/single-cluster/targetCustomization_test.go
+++ b/e2e/single-cluster/targetCustomization_test.go
@@ -33,14 +33,23 @@ var _ = Describe("Helm deploy options", func() {
 		BeforeEach(func() {
 			asset = "single-cluster/namespaceLabels_targetCustomization.yaml"
 		})
-		When("namespaceLabels set as targetCustomization ", func() {
-			It("deploy the bundledeployment with the merged namespaceLabels", func() {
+		When("namespaceLabels and namespaceAnnotations are set as targetCustomization ", func() {
+			It("deploys the bundledeployment with the merged namespaceLabels and namespaceAnnotations", func() {
+				out, err := k.Get("cluster", "local", "-o", "jsonpath={.status.namespace}")
+				Expect(err).ToNot(HaveOccurred(), out)
+
+				clusterNS := strings.TrimSpace(out)
+				clusterK := k.Namespace(clusterNS)
 				Eventually(func() string {
-					bundleDeploymentNames, _ := k.Namespace("cluster-fleet-local-local-1a3d67d0a899").Get("bundledeployments", "-o", "jsonpath={.items[*].metadata.name}")
+					bundleDeploymentNames, _ := clusterK.Get(
+						"bundledeployments",
+						"-o",
+						"jsonpath={.items[*].metadata.name}",
+					)
 
 					var bundleDeploymentName string
 					for _, podName := range strings.Split(bundleDeploymentNames, " ") {
-						if strings.HasPrefix(podName, "test-namespacelabels-targetcustomization") {
+						if strings.HasPrefix(podName, "test-target-customization-namespace-labels") {
 							bundleDeploymentName = podName
 							break
 						}
@@ -49,16 +58,25 @@ var _ = Describe("Helm deploy options", func() {
 						return "nil"
 					}
 
-					bundleDeploymentNamespacesLabels, _ := k.Namespace("cluster-fleet-local-local-1a3d67d0a899").Get("bundledeployments", bundleDeploymentName, "-o", "jsonpath={.spec.options.namespaceLabels}")
+					bundleDeploymentNamespacesLabels, _ := clusterK.Get(
+						"bundledeployments",
+						bundleDeploymentName,
+						"-o",
+						"jsonpath={.spec.options.namespaceLabels}",
+					)
 					return bundleDeploymentNamespacesLabels
 				}).Should(Equal(`{"foo":"bar","this.is/a":"test"}`))
 
 				Eventually(func() string {
-					bundleDeploymentNames, _ := k.Namespace("cluster-fleet-local-local-1a3d67d0a899").Get("bundledeployments", "-o", "jsonpath={.items[*].metadata.name}")
+					bundleDeploymentNames, _ := clusterK.Get(
+						"bundledeployments",
+						"-o",
+						"jsonpath={.items[*].metadata.name}",
+					)
 
 					var bundleDeploymentName string
 					for _, podName := range strings.Split(bundleDeploymentNames, " ") {
-						if strings.HasPrefix(podName, "test-namespacelabels-targetcustomization") {
+						if strings.HasPrefix(podName, "test-target-customization-namespace-labels") {
 							bundleDeploymentName = podName
 							break
 						}
@@ -67,7 +85,12 @@ var _ = Describe("Helm deploy options", func() {
 						return "nil"
 					}
 
-					bundleDeploymentNamespacesLabels, _ := k.Namespace("cluster-fleet-local-local-1a3d67d0a899").Get("bundledeployments", bundleDeploymentName, "-o", "jsonpath={.spec.options.namespaceAnnotations}")
+					bundleDeploymentNamespacesLabels, _ := clusterK.Get(
+						"bundledeployments",
+						bundleDeploymentName,
+						"-o",
+						"jsonpath={.spec.options.namespaceAnnotations}",
+					)
 					return bundleDeploymentNamespacesLabels
 				}).Should(Equal(`{"foo":"bar","this.is/a":"test"}`))
 
diff --git a/integrationtests/agent/suite_test.go b/integrationtests/agent/suite_test.go
index f7e5195a..e4256941 100644
--- a/integrationtests/agent/suite_test.go
+++ b/integrationtests/agent/suite_test.go
@@ -185,7 +185,9 @@ func newReconciler(ctx context.Context, mgr manager.Manager, lookup *lookup) *co
 		localDynamic,
 		helmDeployer,
 		fleetNamespace,
-		defaultNamespace)
+		defaultNamespace,
+		0,
+	)
 
 	return &controller.BundleDeploymentReconciler{
 		Client: upstreamClient,
diff --git a/integrationtests/gitjob/controller/controller_test.go b/integrationtests/gitjob/controller/controller_test.go
index 30baa117..621549a0 100644
--- a/integrationtests/gitjob/controller/controller_test.go
+++ b/integrationtests/gitjob/controller/controller_test.go
@@ -1,6 +1,7 @@
 package controller
 
 import (
+	"context"
 	"encoding/hex"
 	"fmt"
 	"math/rand"
@@ -145,6 +146,27 @@ var _ = Describe("GitJob controller", func() {
 					g.Expect(checkCondition(&gitRepo, "Ready", corev1.ConditionTrue, "")).To(BeTrue())
 					g.Expect(checkCondition(&gitRepo, "Accepted", corev1.ConditionTrue, "")).To(BeTrue())
 				}).Should(Succeed())
+
+				// it should log 2 events
+				// first one is to log the new commit from the poller
+				// second one is to inform that the job was created
+				Eventually(func(g Gomega) {
+					events, _ := k8sClientSet.CoreV1().Events(gitRepo.Namespace).List(context.TODO(),
+						metav1.ListOptions{
+							FieldSelector: "involvedObject.name=success",
+							TypeMeta:      metav1.TypeMeta{Kind: "GitRepo"},
+						})
+					g.Expect(events).ToNot(BeNil())
+					g.Expect(len(events.Items)).To(Equal(2))
+					g.Expect(events.Items[0].Reason).To(Equal("GotNewCommit"))
+					g.Expect(events.Items[0].Message).To(Equal("9ca3a0ad308ed8bffa6602572e2a1343af9c3d2e"))
+					g.Expect(events.Items[0].Type).To(Equal("Normal"))
+					g.Expect(events.Items[0].Source.Component).To(Equal("gitjob-controller"))
+					g.Expect(events.Items[1].Reason).To(Equal("Created"))
+					g.Expect(events.Items[1].Message).To(Equal("GitJob was created"))
+					g.Expect(events.Items[1].Type).To(Equal("Normal"))
+					g.Expect(events.Items[1].Source.Component).To(Equal("gitjob-controller"))
+				}).Should(Succeed())
 			})
 		})
 
@@ -345,6 +367,31 @@ var _ = Describe("GitJob controller", func() {
 
 				return string(job.UID) != string(newJob.UID)
 			}).Should(BeTrue())
+			// it should log 3 events
+			// first one is to log the new commit from the poller
+			// second one is to inform that the job was created
+			// third one reports on the job being deleted because of ForceUpdateGeneration
+			Eventually(func(g Gomega) {
+				events, _ := k8sClientSet.CoreV1().Events(gitRepo.Namespace).List(context.TODO(),
+					metav1.ListOptions{
+						FieldSelector: "involvedObject.name=force-deletion",
+						TypeMeta:      metav1.TypeMeta{Kind: "GitRepo"},
+					})
+				g.Expect(events).ToNot(BeNil())
+				g.Expect(len(events.Items)).To(Equal(3))
+				g.Expect(events.Items[0].Reason).To(Equal("GotNewCommit"))
+				g.Expect(events.Items[0].Message).To(Equal("9ca3a0ad308ed8bffa6602572e2a1343af9c3d2e"))
+				g.Expect(events.Items[0].Type).To(Equal("Normal"))
+				g.Expect(events.Items[0].Source.Component).To(Equal("gitjob-controller"))
+				g.Expect(events.Items[1].Reason).To(Equal("Created"))
+				g.Expect(events.Items[1].Message).To(Equal("GitJob was created"))
+				g.Expect(events.Items[1].Type).To(Equal("Normal"))
+				g.Expect(events.Items[1].Source.Component).To(Equal("gitjob-controller"))
+				g.Expect(events.Items[2].Reason).To(Equal("JobDeleted"))
+				g.Expect(events.Items[2].Message).To(Equal("job deletion triggered because of ForceUpdateGeneration"))
+				g.Expect(events.Items[2].Type).To(Equal("Normal"))
+				g.Expect(events.Items[2].Source.Component).To(Equal("gitjob-controller"))
+			}).Should(Succeed())
 		})
 
 		It("verifies that UpdateGeneration in Status is equal to ForceSyncGeneration", func() {
diff --git a/integrationtests/gitjob/controller/suite_test.go b/integrationtests/gitjob/controller/suite_test.go
index 6f692eb5..237e85cf 100644
--- a/integrationtests/gitjob/controller/suite_test.go
+++ b/integrationtests/gitjob/controller/suite_test.go
@@ -11,12 +11,6 @@ import (
 	. "github.com/onsi/gomega"
 	"github.com/reugn/go-quartz/quartz"
 	"go.uber.org/mock/gomock"
-	"k8s.io/client-go/kubernetes/scheme"
-	"k8s.io/client-go/rest"
-	ctrl "sigs.k8s.io/controller-runtime"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-	"sigs.k8s.io/controller-runtime/pkg/envtest"
-	"sigs.k8s.io/controller-runtime/pkg/log/zap"
 
 	"github.com/rancher/fleet/internal/cmd/controller/gitops/reconciler"
 	ctrlreconciler "github.com/rancher/fleet/internal/cmd/controller/reconciler"
@@ -25,6 +19,14 @@ import (
 	"github.com/rancher/fleet/internal/manifest"
 	v1alpha1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	"github.com/rancher/fleet/pkg/git/mocks"
+
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/client-go/rest"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/envtest"
+	"sigs.k8s.io/controller-runtime/pkg/log/zap"
 )
 
 const (
@@ -40,6 +42,7 @@ var (
 	logsBuffer     bytes.Buffer
 	namespace      string
 	expectedCommit string
+	k8sClientSet   *kubernetes.Clientset
 )
 
 func TestGitJobController(t *testing.T) {
@@ -63,6 +66,9 @@ var _ = BeforeSuite(func() {
 	err = v1alpha1.AddToScheme(scheme.Scheme)
 	Expect(err).NotTo(HaveOccurred())
 
+	k8sClientSet, err = kubernetes.NewForConfig(cfg)
+	Expect(err).NotTo(HaveOccurred())
+
 	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
 	Expect(err).NotTo(HaveOccurred())
 	Expect(k8sClient).NotTo(BeNil())
@@ -98,6 +104,7 @@ var _ = BeforeSuite(func() {
 		Scheduler:  sched,
 		GitFetcher: fetcherMock,
 		Clock:      reconciler.RealClock{},
+		Recorder:   mgr.GetEventRecorderFor("gitjob-controller"),
 	}).SetupWithManager(mgr)
 	Expect(err).ToNot(HaveOccurred())
 
diff --git a/internal/cmd/agent/deployer/cleanup/cleanup_test.go b/internal/cmd/agent/deployer/cleanup/cleanup_test.go
new file mode 100644
index 00000000..ba561394
--- /dev/null
+++ b/internal/cmd/agent/deployer/cleanup/cleanup_test.go
@@ -0,0 +1,89 @@
+//go:generate mockgen --build_flags=--mod=mod -destination=../../../../mocks/helm_deployer_mock.go -package=mocks github.com/rancher/fleet/internal/cmd/agent/deployer/cleanup HelmDeployer
+
+package cleanup
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/rancher/fleet/internal/helmdeployer"
+	"github.com/rancher/fleet/internal/mocks"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	"go.uber.org/mock/gomock"
+	"k8s.io/apimachinery/pkg/types"
+	"sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+func TestCleanupReleases(t *testing.T) {
+	fleetNS := "foo"   // Used to get bundle deployments by bundle ID
+	defaultNS := "bar" // Used to compute the expected release key
+
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+
+	deployedBundles := []helmdeployer.DeployedBundle{
+		{
+			BundleID:    "ID1",
+			ReleaseName: fmt.Sprintf("%s/TestRelease1", defaultNS),
+		},
+		{
+			BundleID:    "ID2",
+			ReleaseName: fmt.Sprintf("%s/TestRelease2", defaultNS),
+		},
+		{
+			BundleID:    "ID3",
+			ReleaseName: fmt.Sprintf("%s/TestRelease3", defaultNS),
+		},
+	}
+
+	mockClient := mocks.NewMockClient(mockCtrl)
+	bd := &fleet.BundleDeployment{}
+	mockClient.EXPECT().Get(gomock.Any(), types.NamespacedName{Namespace: fleetNS, Name: "ID1"}, bd).DoAndReturn(
+		func(_ context.Context, _ types.NamespacedName, bd *fleet.BundleDeployment, _ ...interface{}) error {
+			bd.Spec.Options.TargetNamespace = defaultNS
+			bd.Spec.Options.Helm = &fleet.HelmOptions{
+				ReleaseName: "TestRelease1", // will be kept
+			}
+
+			return nil
+		},
+	)
+
+	mockClient.EXPECT().Get(gomock.Any(), types.NamespacedName{Namespace: fleetNS, Name: "ID2"}, bd).DoAndReturn(
+		func(_ context.Context, _ types.NamespacedName, bd *fleet.BundleDeployment, _ ...interface{}) error {
+			bd.Spec.Options.TargetNamespace = defaultNS
+			bd.Spec.Options.Helm = &fleet.HelmOptions{
+				ReleaseName: "TestRelease2-old", // will be deleted
+			}
+
+			return nil
+		},
+	)
+
+	mockClient.EXPECT().Get(gomock.Any(), types.NamespacedName{Namespace: fleetNS, Name: "ID3"}, bd).DoAndReturn(
+		func(_ context.Context, _ types.NamespacedName, bd *fleet.BundleDeployment, _ ...interface{}) error {
+			bd.Spec.Options.TargetNamespace = defaultNS + "-old" // will be deleted
+			bd.Spec.Options.Helm = &fleet.HelmOptions{
+				ReleaseName: "TestRelease3",
+			}
+
+			return nil
+		},
+	)
+
+	mockHelmDeployer := mocks.NewMockHelmDeployer(mockCtrl)
+	mockHelmDeployer.EXPECT().NewListAction()
+	mockHelmDeployer.EXPECT().ListDeployments(gomock.Any()).Return(deployedBundles, nil)
+	mockHelmDeployer.EXPECT().DeleteRelease(gomock.Any(), deployedBundles[1]).Return(nil)
+	mockHelmDeployer.EXPECT().DeleteRelease(gomock.Any(), deployedBundles[2]).Return(nil)
+
+	cleanup := New(mockClient, nil, nil, mockHelmDeployer, fleetNS, defaultNS, 1*time.Second)
+
+	err := cleanup.cleanup(context.Background(), log.FromContext(context.Background()).WithName("test"))
+
+	if err != nil {
+		t.Errorf("cleanup failed: %v", err)
+	}
+}
diff --git a/internal/cmd/agent/deployer/deployer_test.go b/internal/cmd/agent/deployer/deployer_test.go
index c12c8fc7..71f6e4c8 100644
--- a/internal/cmd/agent/deployer/deployer_test.go
+++ b/internal/cmd/agent/deployer/deployer_test.go
@@ -23,11 +23,34 @@ func TestSetNamespaceLabelsAndAnnotations(t *testing.T) {
 		release    string
 		expectedNs corev1.Namespace
 	}{
+		"Empty sets of NamespaceLabels and NamespaceAnnotations are supported": {
+			bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+				Options: fleet.BundleDeploymentOptions{
+					NamespaceLabels:      nil, // equivalent to map[string]string{}
+					NamespaceAnnotations: nil,
+				},
+			}},
+			ns: corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:   "namespace",
+					Labels: map[string]string{"kubernetes.io/metadata.name": "namespace"},
+				},
+			},
+			release: "namespace/foo/bar",
+			expectedNs: corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:        "namespace",
+					Labels:      map[string]string{"kubernetes.io/metadata.name": "namespace"},
+					Annotations: nil,
+				},
+			},
+		},
+
 		"NamespaceLabels and NamespaceAnnotations are appended": {
 			bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
 				Options: fleet.BundleDeploymentOptions{
-					NamespaceLabels:      &map[string]string{"optLabel1": "optValue1", "optLabel2": "optValue2"},
-					NamespaceAnnotations: &map[string]string{"optAnn1": "optValue1"},
+					NamespaceLabels:      map[string]string{"optLabel1": "optValue1", "optLabel2": "optValue2"},
+					NamespaceAnnotations: map[string]string{"optAnn1": "optValue1"},
 				},
 			}},
 			ns: corev1.Namespace{
@@ -49,8 +72,8 @@ func TestSetNamespaceLabelsAndAnnotations(t *testing.T) {
 		"NamespaceLabels and NamespaceAnnotations removes entries that are not in the options, except the name label": {
 			bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
 				Options: fleet.BundleDeploymentOptions{
-					NamespaceLabels:      &map[string]string{"optLabel": "optValue"},
-					NamespaceAnnotations: &map[string]string{},
+					NamespaceLabels:      map[string]string{"optLabel": "optValue"},
+					NamespaceAnnotations: map[string]string{},
 				},
 			}},
 			ns: corev1.Namespace{
@@ -73,8 +96,8 @@ func TestSetNamespaceLabelsAndAnnotations(t *testing.T) {
 		"NamespaceLabels and NamespaceAnnotations updates existing values": {
 			bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
 				Options: fleet.BundleDeploymentOptions{
-					NamespaceLabels:      &map[string]string{"bdLabel": "labelUpdated"},
-					NamespaceAnnotations: &map[string]string{"bdAnn": "annUpdated"},
+					NamespaceLabels:      map[string]string{"bdLabel": "labelUpdated"},
+					NamespaceAnnotations: map[string]string{"bdAnn": "annUpdated"},
 				},
 			}},
 			ns: corev1.Namespace{
@@ -130,8 +153,8 @@ func TestSetNamespaceLabelsAndAnnotations(t *testing.T) {
 func TestSetNamespaceLabelsAndAnnotationsError(t *testing.T) {
 	bd := &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
 		Options: fleet.BundleDeploymentOptions{
-			NamespaceLabels:      &map[string]string{"optLabel1": "optValue1", "optLabel2": "optValue2"},
-			NamespaceAnnotations: &map[string]string{"optAnn1": "optValue1"},
+			NamespaceLabels:      map[string]string{"optLabel1": "optValue1", "optLabel2": "optValue2"},
+			NamespaceAnnotations: map[string]string{"optAnn1": "optValue1"},
 		},
 	}}
 	release := "test/foo/bar"
diff --git a/internal/cmd/controller/gitops/reconciler/gitjob_test.go b/internal/cmd/controller/gitops/reconciler/gitjob_test.go
index 1d367a1b..58e7564d 100644
--- a/internal/cmd/controller/gitops/reconciler/gitjob_test.go
+++ b/internal/cmd/controller/gitops/reconciler/gitjob_test.go
@@ -16,13 +16,17 @@ import (
 	"github.com/rancher/fleet/internal/cmd/controller/finalize"
 	"github.com/rancher/fleet/internal/mocks"
 	fleetv1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	v1alpha1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	"github.com/rancher/wrangler/v3/pkg/genericcondition"
 
+	fleetevent "github.com/rancher/fleet/pkg/event"
 	gitmocks "github.com/rancher/fleet/pkg/git/mocks"
 	batchv1 "k8s.io/api/batch/v1"
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	ctrl "sigs.k8s.io/controller-runtime"
@@ -31,6 +35,15 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
 )
 
+func getCondition(gitrepo *v1alpha1.GitRepo, condType string) (genericcondition.GenericCondition, bool) {
+	for _, cond := range gitrepo.Status.Conditions {
+		if cond.Type == condType {
+			return cond, true
+		}
+	}
+	return genericcondition.GenericCondition{}, false
+}
+
 type ClockMock struct {
 	t time.Time
 }
@@ -38,6 +51,36 @@ type ClockMock struct {
 func (m ClockMock) Now() time.Time                  { return m.t }
 func (m ClockMock) Since(t time.Time) time.Duration { return m.t.Sub(t) }
 
+// gitRepoMatcher implements a gomock matcher that checks for gitrepos.
+// It only checks for the expected name and namespace so far
+type gitRepoMatcher struct {
+	gitrepo fleetv1.GitRepo
+}
+
+func (m gitRepoMatcher) Matches(x interface{}) bool {
+	gitrepo, ok := x.(*fleetv1.GitRepo)
+	if !ok {
+		return false
+	}
+	return m.gitrepo.Name == gitrepo.Name && m.gitrepo.Namespace == gitrepo.Namespace
+}
+
+func (m gitRepoMatcher) String() string {
+	return fmt.Sprintf("Gitrepo %s-%s", m.gitrepo.Name, m.gitrepo.Namespace)
+}
+
+type gitRepoPointerMatcher struct {
+}
+
+func (m gitRepoPointerMatcher) Matches(x interface{}) bool {
+	_, ok := x.(*fleetv1.GitRepo)
+	return ok
+}
+
+func (m gitRepoPointerMatcher) String() string {
+	return ""
+}
+
 func getGitPollingCondition(gitrepo *fleetv1.GitRepo) (genericcondition.GenericCondition, bool) {
 	for _, cond := range gitrepo.Status.Conditions {
 		if cond.Type == gitPollingCondition {
@@ -146,12 +189,20 @@ func TestReconcile_LatestCommitErrorIsSetInConditions(t *testing.T) {
 		},
 	).Times(1)
 
+	recorderMock := mocks.NewMockEventRecorder(mockCtrl)
+	recorderMock.EXPECT().Event(
+		&gitRepoMatcher{gitRepo},
+		fleetevent.Warning,
+		"FailedToCheckCommit",
+		"TEST ERROR",
+	)
 	r := GitJobReconciler{
 		Client:     client,
 		Scheme:     scheme,
 		Image:      "",
 		GitFetcher: fetcher,
 		Clock:      RealClock{},
+		Recorder:   recorderMock,
 	}
 
 	ctx := context.TODO()
@@ -214,12 +265,21 @@ func TestReconcile_LatestCommitIsOkay(t *testing.T) {
 		},
 	).Times(1)
 
+	recorderMock := mocks.NewMockEventRecorder(mockCtrl)
+	recorderMock.EXPECT().Event(
+		&gitRepoMatcher{gitRepo},
+		fleetevent.Normal,
+		"GotNewCommit",
+		"1883fd54bc5dfd225acf02aecbb6cb8020458e33",
+	)
+
 	r := GitJobReconciler{
 		Client:     client,
 		Scheme:     scheme,
 		Image:      "",
 		GitFetcher: fetcher,
 		Clock:      RealClock{},
+		Recorder:   recorderMock,
 	}
 
 	ctx := context.TODO()
@@ -348,12 +408,21 @@ func TestReconcile_LatestCommitShouldBeCalled(t *testing.T) {
 		},
 	).Times(1)
 
+	recorderMock := mocks.NewMockEventRecorder(mockCtrl)
+	recorderMock.EXPECT().Event(
+		&gitRepoMatcher{gitRepo},
+		fleetevent.Normal,
+		"GotNewCommit",
+		"1883fd54bc5dfd225acf02aecbb6cb8020458e33",
+	)
+
 	r := GitJobReconciler{
 		Client:     client,
 		Scheme:     scheme,
 		Image:      "",
 		GitFetcher: fetcher,
 		Clock:      RealClock{},
+		Recorder:   recorderMock,
 	}
 
 	ctx := context.TODO()
@@ -387,7 +456,14 @@ func TestReconcile_Error_WhenGitrepoRestrictionsAreNotMet(t *testing.T) {
 			return nil
 		},
 	)
-	mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
+
+	mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), &gitRepoPointerMatcher{}, gomock.Any()).Times(2).DoAndReturn(
+		func(ctx context.Context, req types.NamespacedName, gitrepo *fleetv1.GitRepo, opts ...interface{}) error {
+			gitrepo.Name = gitRepo.Name
+			gitrepo.Namespace = gitRepo.Namespace
+			return nil
+		},
+	)
 	statusClient := mocks.NewMockSubResourceWriter(mockCtrl)
 	mockClient.EXPECT().Status().Times(1).Return(statusClient)
 	statusClient.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any()).Do(
@@ -401,11 +477,20 @@ func TestReconcile_Error_WhenGitrepoRestrictionsAreNotMet(t *testing.T) {
 		},
 	)
 
+	recorderMock := mocks.NewMockEventRecorder(mockCtrl)
+	recorderMock.EXPECT().Event(
+		&gitRepoMatcher{gitRepo},
+		fleetevent.Warning,
+		"FailedToApplyRestrictions",
+		"empty targetNamespace denied, because allowedTargetNamespaces restriction is present",
+	)
+
 	r := GitJobReconciler{
-		Client: mockClient,
-		Scheme: scheme,
-		Image:  "",
-		Clock:  RealClock{},
+		Client:   mockClient,
+		Scheme:   scheme,
+		Image:    "",
+		Clock:    RealClock{},
+		Recorder: recorderMock,
 	}
 
 	ctx := context.TODO()
@@ -418,6 +503,164 @@ func TestReconcile_Error_WhenGitrepoRestrictionsAreNotMet(t *testing.T) {
 	}
 }
 
+func TestReconcile_Error_WhenGetGitJobErrors(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+	scheme := runtime.NewScheme()
+	utilruntime.Must(batchv1.AddToScheme(scheme))
+	gitRepo := fleetv1.GitRepo{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "gitrepo",
+			Namespace: "default",
+		},
+	}
+	namespacedName := types.NamespacedName{Name: gitRepo.Name, Namespace: gitRepo.Namespace}
+	mockClient := mocks.NewMockClient(mockCtrl)
+	mockClient.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
+
+	mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1).DoAndReturn(
+		func(ctx context.Context, req types.NamespacedName, gitrepo *fleetv1.GitRepo, opts ...interface{}) error {
+			gitrepo.Name = gitRepo.Name
+			gitrepo.Namespace = gitRepo.Namespace
+			gitrepo.Spec.Repo = "repo"
+			controllerutil.AddFinalizer(gitrepo, finalize.GitRepoFinalizer)
+			gitrepo.Status.Commit = "dd45c7ad68e10307765104fea4a1f5997643020f"
+			return nil
+		},
+	)
+	mockFetcher := gitmocks.NewMockGitFetcher(mockCtrl)
+	commit := "1883fd54bc5dfd225acf02aecbb6cb8020458e33"
+	mockFetcher.EXPECT().LatestCommit(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return(commit, nil)
+
+	mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1).DoAndReturn(
+		func(ctx context.Context, req types.NamespacedName, job *batchv1.Job, opts ...interface{}) error {
+			return fmt.Errorf("GITJOB ERROR")
+		},
+	)
+
+	recorderMock := mocks.NewMockEventRecorder(mockCtrl)
+	recorderMock.EXPECT().Event(
+		&gitRepoMatcher{gitRepo},
+		fleetevent.Normal,
+		"GotNewCommit",
+		"1883fd54bc5dfd225acf02aecbb6cb8020458e33",
+	)
+	recorderMock.EXPECT().Event(
+		&gitRepoMatcher{gitRepo},
+		fleetevent.Warning,
+		"FailedToGetGitJob",
+		"error retrieving git job: GITJOB ERROR",
+	)
+
+	r := GitJobReconciler{
+		Client:     mockClient,
+		Scheme:     scheme,
+		Image:      "",
+		Clock:      RealClock{},
+		Recorder:   recorderMock,
+		GitFetcher: mockFetcher,
+	}
+
+	ctx := context.TODO()
+	_, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: namespacedName})
+	if err == nil {
+		t.Errorf("expecting an error, got nil")
+	}
+	if err.Error() != "error retrieving git job: GITJOB ERROR" {
+		t.Errorf("unexpected error %v", err)
+	}
+}
+
+func TestReconcile_Error_WhenSecretDoesNotExist(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+	scheme := runtime.NewScheme()
+	utilruntime.Must(batchv1.AddToScheme(scheme))
+	gitRepo := fleetv1.GitRepo{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "gitrepo",
+			Namespace: "default",
+		},
+	}
+	namespacedName := types.NamespacedName{Name: gitRepo.Name, Namespace: gitRepo.Namespace}
+	mockClient := mocks.NewMockClient(mockCtrl)
+	mockClient.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(nil)
+
+	mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), &gitRepoPointerMatcher{}, gomock.Any()).Times(2).DoAndReturn(
+		func(ctx context.Context, req types.NamespacedName, gitrepo *fleetv1.GitRepo, opts ...interface{}) error {
+			gitrepo.Name = gitRepo.Name
+			gitrepo.Namespace = gitRepo.Namespace
+			gitrepo.Spec.Repo = "repo"
+			gitrepo.Spec.HelmSecretNameForPaths = "somevalue"
+			controllerutil.AddFinalizer(gitrepo, finalize.GitRepoFinalizer)
+			gitrepo.Status.Commit = "dd45c7ad68e10307765104fea4a1f5997643020f"
+			return nil
+		},
+	)
+	mockFetcher := gitmocks.NewMockGitFetcher(mockCtrl)
+	commit := "1883fd54bc5dfd225acf02aecbb6cb8020458e33"
+	mockFetcher.EXPECT().LatestCommit(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return(commit, nil)
+
+	// we need to return a NotFound error, so the code tries to create it.
+	mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1).DoAndReturn(
+		func(ctx context.Context, req types.NamespacedName, job *batchv1.Job, opts ...interface{}) error {
+			return errors.NewNotFound(schema.GroupResource{}, "TEST ERROR")
+		},
+	)
+
+	mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(1).DoAndReturn(
+		func(ctx context.Context, req types.NamespacedName, job *corev1.Secret, opts ...interface{}) error {
+			return fmt.Errorf("SECRET ERROR")
+		},
+	)
+
+	recorderMock := mocks.NewMockEventRecorder(mockCtrl)
+	recorderMock.EXPECT().Event(
+		&gitRepoMatcher{gitRepo},
+		fleetevent.Normal,
+		"GotNewCommit",
+		"1883fd54bc5dfd225acf02aecbb6cb8020458e33",
+	)
+	recorderMock.EXPECT().Event(
+		&gitRepoMatcher{gitRepo},
+		fleetevent.Warning,
+		"FailedValidatingSecret",
+		"failed to look up HelmSecretNameForPaths, error: SECRET ERROR",
+	)
+
+	statusClient := mocks.NewMockSubResourceWriter(mockCtrl)
+	mockClient.EXPECT().Status().Times(1).Return(statusClient)
+	statusClient.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any()).Do(
+		func(ctx context.Context, repo *fleetv1.GitRepo, opts ...interface{}) {
+			c, found := getCondition(repo, fleetv1.GitRepoAcceptedCondition)
+			if !found {
+				t.Errorf("expecting to find the %s condition and could not find it.", fleetv1.GitRepoAcceptedCondition)
+			}
+			if c.Message != "failed to look up HelmSecretNameForPaths, error: SECRET ERROR" {
+				t.Errorf("expecting message [failed to look up HelmSecretNameForPaths, error: SECRET ERROR] in condition, got [%s]", c.Message)
+			}
+		},
+	)
+
+	r := GitJobReconciler{
+		Client:     mockClient,
+		Scheme:     scheme,
+		Image:      "",
+		Clock:      RealClock{},
+		Recorder:   recorderMock,
+		GitFetcher: mockFetcher,
+	}
+
+	ctx := context.TODO()
+	_, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: namespacedName})
+	if err == nil {
+		t.Errorf("expecting an error, got nil")
+	}
+	if err.Error() != "failed to look up HelmSecretNameForPaths, error: SECRET ERROR" {
+		t.Errorf("unexpected error %v", err)
+	}
+}
+
 func TestNewJob(t *testing.T) { // nolint:funlen
 	securityContext := &corev1.SecurityContext{
 		AllowPrivilegeEscalation: &[]bool{false}[0],
diff --git a/internal/manifest/store_test.go b/internal/manifest/store_test.go
index e012bf54..94097206 100644
--- a/internal/manifest/store_test.go
+++ b/internal/manifest/store_test.go
@@ -1,4 +1,4 @@
-package manifest
+package manifest_test
 
 import (
 	"bytes"
@@ -11,14 +11,15 @@ import (
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/types"
 
+	"github.com/rancher/fleet/internal/manifest"
 	"github.com/rancher/fleet/internal/mocks"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 )
 
-func testManifest(t *testing.T, data string) *Manifest {
+func testManifest(t *testing.T, data string) *manifest.Manifest {
 	t.Helper()
 
-	m, err := FromJSON([]byte(data), "")
+	m, err := manifest.FromJSON([]byte(data), "")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -35,7 +36,7 @@ func Test_contentStore_Store(t *testing.T) {
 	resources := `{"resources": [{"name": "foo", "content": "bar"}]}`
 	checksum := "752ebbb975f52eea5e87950ef2ca5de4055de3c68a17f54d94527d7fd79c21fd"
 	type args struct {
-		manifest *Manifest
+		manifest *manifest.Manifest
 		cached   bool
 	}
 	tests := []struct {
@@ -48,7 +49,7 @@ func Test_contentStore_Store(t *testing.T) {
 			args: args{
 				manifest: testManifest(t, resources),
 			},
-			want: toSHA256ID(checksum),
+			want: manifest.ToSHA256ID(checksum),
 		},
 		{
 			name: "existing manifest",
@@ -56,7 +57,7 @@ func Test_contentStore_Store(t *testing.T) {
 				manifest: testManifest(t, resources),
 				cached:   true,
 			},
-			want: toSHA256ID(checksum),
+			want: manifest.ToSHA256ID(checksum),
 		},
 	}
 	for _, tt := range tests {
@@ -64,7 +65,7 @@ func Test_contentStore_Store(t *testing.T) {
 			ctrl := gomock.NewController(t)
 			client := mocks.NewMockClient(ctrl)
 
-			store := &ContentStore{client}
+			store := &manifest.ContentStore{client}
 			ctx := context.TODO()
 			nsn := types.NamespacedName{Name: tt.want}
 
diff --git a/pkg/webhook/webhook_test.go b/pkg/webhook/webhook_test.go
index 1b9bf64b..53dd5d4a 100644
--- a/pkg/webhook/webhook_test.go
+++ b/pkg/webhook/webhook_test.go
@@ -9,8 +9,8 @@ import (
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/kubectl/pkg/scheme"
 
+	"github.com/go-playground/webhooks/v6/azuredevops"
 	v1alpha1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
-	"github.com/rancher/fleet/pkg/webhook/azuredevops"
 	"gopkg.in/go-playground/webhooks.v5/github"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	cfake "sigs.k8s.io/controller-runtime/pkg/client/fake"