Repository: rancher/fleet

Issue:
[SURE-8882] extend our testing to ssh helm chart downloads with keys
## Acceptance Criteria

- [ ]  write a test for Helm chart downloads via SSH as in https://github.com/rancher/fleet/blob/43cf0a41330c57c3d1b853e00ab66ab2c1899d6a/internal/bundlereader/loaddirectory.go#L242C2-L270C3


## SURE-8882

### Issue description:

The customer upgraded from Rancher 2.8.2 to Rancher 2.8.5 and some of their upstream fleet jobs are getting this error:
~~~
time=2024-07-30 15:16:18.000000 level=fatal msg="error downloading 'ssh://git@github.com/xxxxx/fleet-platform.git?sshkey=redacted': /usr/bin/git exited with 128: Cloning into '/tmp/getter624252719/temp'...\nNo user exists for uid 1000\r\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.\n"
~~~

### Troubleshooting steps:

The customer  tried changing the credentials and still get the same error.
They are able to clone the repository locally using the same credentials supplied to Fleet. This also happens on most of the configured repositories, not just one or two git repos.
They are able to exec into the gitjob pod and manually clone the repo with success.
Checked from inside the GitJob pod:
~~~
 > kubectl exec -n cattle-fleet-system gitjob-7889c69f49-5kq8r -it -- cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
sshd:x:499:486:SSH daemon:/var/lib/sshd:/usr/sbin/nologin
gitjob:x:1000:1000::/home/gitjob:/bin/bash
~~~

I reviewed two of their GitRepo manifests (working/not-working) and they are literally pointing to the same repo, the only difference is the path used.
The customer dowgraded from 0.9.5 to 0.9.0 and the problem repos started to sync again

### Repro steps:

unable to repro in-house

### Workaround:
Is a workaround available and implemented? yes/no
What is the workaround: Downgrade fleet

### Actual behavior:
After upgrade, some gitrepos fail with error:



~~~
time=2024-07-30 15:16:18.000000 level=fatal msg="error downloading 'ssh://git@github.com/xxxxx/fleet-platform.git?sshkey=redacted': /usr/bin/git exited with 128: Cloning into '/tmp/getter624252719/temp'...\nNo user exists for uid 1000\r\nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\nand the repository exists.\n"
~~~
Expected behavior:
All gitrepos continue to sync with no error

Files, logs, traces:
<text> 

Additional notes:
<text>

Hints from issue comments:
@cienijr: We're facing the same issue here. SSH doesn't work at all when pulling helm charts.
It might be related to https://github.com/rancher/fleet/commit/326ad93d73c70a48b4ed2e1bee4fe955d83491f4, a quick Google search suggests that OpenSSH depends on the entry existing at /etc/passwd for some obscure reason.

Amusingly, using SSH for the GitRepo itself does work (the initcontainer runs `fleet gitcloner` and exits with success). It only seems to be an issue when running `fleet apply`.

We're using Rancher 2.9.3 with fleet 0.10.4@manno: > We're facing the same issue here. SSH doesn't work at all when pulling helm charts. It might be related to [326ad93](https://github.com/rancher/fleet/commit/326ad93d73c70a48b4ed2e1bee4fe955d83491f4), a quick Google search suggests that OpenSSH depends on the entry existing at /etc/passwd for some obscure reason.
> 
> Amusingly, using SSH for the GitRepo itself does work (the initcontainer runs `fleet gitcloner` and exits with success). It only seems to be an issue when running `fleet apply`.

If [downloading a helm chart via ssh](https://fleet.rancher.io/ref-fleet-yaml#reference) fails, that would be a separate issue. The `fleet apply` CLI uses [go-getter](https://github.com/hashicorp/go-getter?tab=readme-ov-file#git-git) to download charts.
Cloning the git repository is done by `fleet gitcloner` and it uses go-git.@cienijr: I looked up the source code for go-getter, and its support for fetching from Git repos indeed relies on running git commands directly, which would trigger the OpenSSH error related to the missing /etc/passwd entry.

From what I could gather, go-git has a Git implementation of its own and it uses `crypto/ssh` for transport instead of OpenSSH - I'm pretty sure that it does not perform this [validation](https://superuser.com/questions/1704482/run-ssh-as-a-non-existent-virtual-user), that seems to be the culprit for this weird requirement.

That's probably why this problem happens when fleet downloads a chart through Git+SSH but not when it fetches a GitRepo using Git+SSH - in our case, even when using the same repo and same credentials.

(I apologize if any of my messages comes out as confusing or maybe rude, as English is not my native language)@antonio-qualtio: Hello, any update on this issue?? We are getting the same error in Fleet version v0.10.4@manno: /backport v2.11.4@p-se: # QA Template

## Reproducing the issue

1. Create a private git repository that can be accessed using an SSH key and can contain any helm chart to deploy.
1. Create a secret that contains the key to access this repository.    
    ```sh
    kubectl -n fleet-local create secret generic ssh --type "kubernetes.io/ssh-auth" \
        --from-file=ssh-privatekey=./../../keys/deploy
    ```
    In this example, we've created an SSH key named `ssh`.
1. Create a public git repository that contains a `fleet.yaml` file. The `fleet.yaml` has to reference the private git repository like so:    
   ```yaml
    helm
        chart: git::ssh://git@github.com/p-se/fleet-devel.git//issues/SURE-8882/foo?ref=master
    ```
    where `//issues/SURE-8882/foo?ref=master` is optional and provides a path inside the git repository and the branch to be used to clone from.
1. Have the GitRepo resource point to the public git repository with the `fleet.yaml` file.
    For example:
    ```yaml
    kind: GitRepo
    apiVersion: fleet.cattle.io/v1alpha1
    metadata:
      name: sure-8882
      namespace: fleet-local
    spec:
      repo: git@github.com:p-se/fleet-devel.git
      branch: master
      paths:
        - issues/SURE-8882/repro
      clientSecretName: ssh
    ```
1. Apply the GitRepo resource onto the cluster.
2. You should see the `No user exists for uid 1000` message in the status of the GitRepo.

## Notes 

The public repository can also be private, but doesn't have to. By using two private repositories and pointing the resource to the appropriate paths, you can use a single git repository for testing. That's where the provided examples come from.@mmartin24: Verified on Rancher `v2.12-6d2a9d53b44f309eac233e0f21fd9d5e806b056d-head` with Fleet `0.13.0-beta.3` 
Added test case on Fleet case: https://app.qase.io/case/FLEET-184 



----

Reproduced issue before fix:

<img width="1901" height="881" alt="Image" src="https://github.com/user-attachments/assets/f19ec66e-b7df-4593-bfa1-7809ef82626b" />

#### Testing steps:

- Set 2 repos, 1 public with ssh secret to be used pointing a private one and 1 private with the actual chart 

Public with : https://github.com/fleetqa/fleet-qa-examples-public
```yaml
kind: GitRepo
apiVersion: fleet.cattle.io/v1alpha1
metadata:
  name: sure-8882
  namespace: fleet-local
spec:
  repo: git@github.com:fleetqa/fleet-qa-examples-public.git
  branch: main
  paths:
    - sure-8882/reproducer
  clientSecretName: ssh
```

and fleet.yaml pointitng to the ssh chart:
```yaml
 helm:
     chart: git::ssh://git@github.com/fleetqa/fleet-qa-examples.git//helmchart-configmap?ref=main
```

Private repo with the actual chart (configmap chart): https://github.com/fleetqa/fleet-qa-examples/tree/main/helmchart-configmap

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: foo
  namespace: default
data:
  key: value
```

```yaml
apiVersion: v2
name: foo
description: A Helm chart for Kubernetes

# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application

# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0

# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"
```

- Create Deploy Key on private repository


- Upload secret via UI (passing deploy key as ssh)

```yaml
apiVersion: v1
data:
  ssh-privatekey: " add private secret"
kind: Secret
metadata:
  creationTimestamp: null
  name: ssh
  namespace: fleet-local
type: kubernetes.io/ssh-auth
```

- Add repo:

https://github.com/fleetqa/fleet-qa-examples-public.git
main
sure-8882/reproducer
helm ssh key: ssh

- Gitrepo is deployed:
<img width="1904" height="866" alt="Image" src="https://github.com/user-attachments/assets/2944211a-3223-45bd-b3e2-e2b432f97934" />@krumware: Does this work for a private repo for the GitRepo as well as the repo path for the helm chart in fleet.yaml? I was getting the uid 1000 on Rancher 2.11.3. Now retesting on Rancher 2.11.4, following the above QA, I'm getting the error: 
```
Bcrypt_pbkdf: empty password:Job Failed. failed: 1/1time="2025-08-04T19:42:42Z" level=fatal msg="failed to create auth from options for repo=\"git@github.com:myorg/myrepo.git\" branch=\"main\" revision=\"\" path=\"/workspace\": bcrypt_pbkdf: empty password"
```@krumware: Scratch that, it's working. Thanks!

In case others stumble on this, a reminder:
- ssh keys must be generated in PEM format.
- ssh keys must NOT use a passphrase
- ssh keys in PEM do NOT support ed25519 as recommended by github

1. `ssh-keygen -t rsa -b 4096 -m pem -C "my@email.com"`
2. leave passphrase empty
3. create kubernetes secret from private key
```
kubectl -n fleet-local create secret generic ssh --type "kubernetes.io/ssh-auth" \
    --from-file=ssh-privatekey=path/to/privatekey
```
4. `cat path/to/publickey` - add to github.@kkaempf: Thanks @krumware - do we need to fix our documentation ? 🤔 @krumware: I think maybe doing a passthrough on that portion of the docs would be helpful. (and maybe it's there elsewhere, but most troubleshooting and searching took me straight to the fleet and gitrepo reference pages.)

My struggles were:
- Finding a clear example of a workflow where both a gitrepo.yaml and a fleet.yaml are authenticated. The documentation tends to lean on at least one of these being unauthenticated. For gitops newbies it creates a challenge when internal repos will most likely always be private.
- The (necessary) re-use of the word "repository" creates confusion because the authentication for a git repository and a helm repository are different. I wish there was a way to more obviously separate the two.
- It was challenging to discover that the fleet yaml's authentication method is inherited from the method used by the gitrepo yaml
- It's still unclear whether different authentication mechanisms can be used for a gitrepo yaml versus a fleet yaml. (it seems, for example, that a user would need to use the same ssh key for potential physically separated repository servers. - an area where enhancement might be coming with the upstream secrets support)
- Despite warning sections indicating that certain legacy algorithms are required, and that passwords aren't supported, those notes didn't feel cohesive with the rest of the documentation and were not obvious.
- It was pointed out in the Rancher slack (thank you!) about the go-getter urls being used. But that really did not stand out in the documentation which made things feel a little bit too automagic, so there was lots of experimentation needed to get to a working configuration.
- The error messages in the Rancher CI UI were not found in any troubleshooting documentation. Such as the `bcrypt_pbkdf empty password`. I was using a password-protected key, but could not find any way to pass that in, so it embarrassingly took me way too long to find the remark about passwords not being supported.
- Finally, documentation on Github recommends against using deprecated algorithms which are required for fleet (at this point). So that unforunately adds to the complexity for adopters, who then need to troubleshoot why their generated key for a private github repository doesn't work.

I hope that's helpful!@kkaempf: @kakabisht 👆🏻 

Solution patch:
diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml
index d54ca504..c9447d7e 100644
--- a/charts/fleet-crd/templates/crds.yaml
+++ b/charts/fleet-crd/templates/crds.yaml
@@ -5821,6 +5821,12 @@ spec:
                   description: 'DesiredReadyGitRepos is the number of gitrepos for
                     this cluster that
 
+                    are desired to be ready.'
+                  type: integer
+                desiredReadyHelmOps:
+                  description: 'DesiredReadyHelmOps is the number of helmop resources
+                    for this cluster that
+
                     are desired to be ready.'
                   type: integer
                 display:
@@ -5858,6 +5864,10 @@ spec:
                   description: ReadyGitRepos is the number of gitrepos for this cluster
                     that are ready.
                   type: integer
+                readyHelmOps:
+                  description: ReadyHelmOps is the number of helmop resources for
+                    this cluster that are ready.
+                  type: integer
                 resourceCounts:
                   description: ResourceCounts is an aggregate over the ResourceCounts.
                   properties:
diff --git a/dev/setup-fleet-multi-cluster b/dev/setup-fleet-multi-cluster
index 03d641e0..c990e76b 100755
--- a/dev/setup-fleet-multi-cluster
+++ b/dev/setup-fleet-multi-cluster
@@ -22,7 +22,9 @@ ns=${FLEET_E2E_NS_DOWNSTREAM-fleet-default}
 # Wait for clusters to become "ready" by waiting for bundles to become ready.
 num_clusters=$(k3d cluster list -o json | jq -r '.[].name | select( . | contains("downstream") )' | wc -l)
 while [[ $(kubectl get clusters.fleet.cattle.io -n "$ns" | grep '1/1' -c) -ne $num_clusters ]]; do
-    sleep 1
+  sleep 1
 done
 
-kubectl patch clusters.fleet.cattle.io -n "$ns" --all --type=json -p '[{"op": "add", "path": "/metadata/labels/env", "value": "test" }]'
+for cluster in $(kubectl get clusters.fleet.cattle.io -n "$ns" -o jsonpath='{.items[*].metadata.name}'); do
+  kubectl patch clusters.fleet.cattle.io "$cluster" -n "$ns" --type=json -p '[{"op": "add", "path": "/metadata/labels/env", "value": "test" }]'
+done
diff --git a/go.mod b/go.mod
index 57420da2..0b89949d 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,7 @@ module github.com/rancher/fleet
 
 go 1.24.0
 
-toolchain go1.24.4
+toolchain go1.24.5
 
 replace (
 	github.com/imdario/mergo => github.com/imdario/mergo v0.3.16
@@ -15,7 +15,7 @@ require (
 	github.com/Masterminds/sprig/v3 v3.3.0
 	github.com/chartmuseum/helm-push v0.10.4
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
-	github.com/docker/docker v28.3.0+incompatible
+	github.com/docker/docker v28.3.1+incompatible
 	github.com/evanphx/json-patch v5.9.11+incompatible
 	github.com/go-git/go-billy/v5 v5.6.2
 	github.com/go-git/go-git/v5 v5.16.2
@@ -51,15 +51,15 @@ require (
 	golang.org/x/sync v0.15.0
 	gonum.org/v1/gonum v0.16.0
 	gotest.tools v2.2.0+incompatible
-	helm.sh/helm/v3 v3.18.3
-	k8s.io/api v0.33.1
-	k8s.io/apiextensions-apiserver v0.33.1
-	k8s.io/apimachinery v0.33.1
-	k8s.io/cli-runtime v0.33.1
-	k8s.io/client-go v0.33.1
+	helm.sh/helm/v3 v3.18.4
+	k8s.io/api v0.33.2
+	k8s.io/apiextensions-apiserver v0.33.2
+	k8s.io/apimachinery v0.33.2
+	k8s.io/cli-runtime v0.33.2
+	k8s.io/client-go v0.33.2
 	k8s.io/klog/v2 v2.130.1
 	k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff
-	k8s.io/kubectl v0.33.1
+	k8s.io/kubectl v0.33.2
 	k8s.io/kubernetes v1.33.2
 	k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979
 	oras.land/oras-go/v2 v2.6.0
@@ -243,10 +243,10 @@ require (
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
-	k8s.io/apiserver v0.33.1 // indirect
-	k8s.io/code-generator v0.33.1 // indirect
-	k8s.io/component-base v0.33.1 // indirect
-	k8s.io/component-helpers v0.33.1 // indirect
+	k8s.io/apiserver v0.33.2 // indirect
+	k8s.io/code-generator v0.33.2 // indirect
+	k8s.io/component-base v0.33.2 // indirect
+	k8s.io/component-helpers v0.33.2 // indirect
 	k8s.io/controller-manager v0.32.1 // indirect
 	k8s.io/gengo v0.0.0-20250130153323-76c5745d3511 // indirect
 	k8s.io/gengo/v2 v2.0.0-20250207200755-1244d31929d7 // indirect
diff --git a/go.sum b/go.sum
index dd9acdf2..df96d34e 100644
--- a/go.sum
+++ b/go.sum
@@ -735,8 +735,8 @@ github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsy
 github.com/docker/cli v28.2.2+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 v28.3.0+incompatible h1:ffS62aKWupCWdvcee7nBU9fhnmknOqDPaJAMtfK0ImQ=
-github.com/docker/docker v28.3.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v28.3.1+incompatible h1:20+BmuA9FXlCX4ByQ0vYJcUEnOmRM6XljDnFWR+jCyY=
+github.com/docker/docker v28.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
 github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
@@ -2079,8 +2079,8 @@ gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
 gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
-helm.sh/helm/v3 v3.18.3 h1:+cvyGKgs7Jt7BN3Klmb4SsG4IkVpA7GAZVGvMz6VO4I=
-helm.sh/helm/v3 v3.18.3/go.mod h1:wUc4n3txYBocM7S9RjTeZBN9T/b5MjffpcSsWEjSIpw=
+helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ=
+helm.sh/helm/v3 v3.18.4/go.mod h1:WVnwKARAw01iEdjpEkP7Ii1tT1pTPYfM1HsakFKM3LI=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -2089,24 +2089,24 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
-k8s.io/api v0.33.1 h1:tA6Cf3bHnLIrUK4IqEgb2v++/GYUtqiu9sRVk3iBXyw=
-k8s.io/api v0.33.1/go.mod h1:87esjTn9DRSRTD4fWMXamiXxJhpOIREjWOSjsW1kEHw=
-k8s.io/apiextensions-apiserver v0.33.1 h1:N7ccbSlRN6I2QBcXevB73PixX2dQNIW0ZRuguEE91zI=
-k8s.io/apiextensions-apiserver v0.33.1/go.mod h1:uNQ52z1A1Gu75QSa+pFK5bcXc4hq7lpOXbweZgi4dqA=
-k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4=
-k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
-k8s.io/apiserver v0.33.1 h1:yLgLUPDVC6tHbNcw5uE9mo1T6ELhJj7B0geifra3Qdo=
-k8s.io/apiserver v0.33.1/go.mod h1:VMbE4ArWYLO01omz+k8hFjAdYfc3GVAYPrhP2tTKccs=
-k8s.io/cli-runtime v0.33.1 h1:TvpjEtF71ViFmPeYMj1baZMJR4iWUEplklsUQ7D3quA=
-k8s.io/cli-runtime v0.33.1/go.mod h1:9dz5Q4Uh8io4OWCLiEf/217DXwqNgiTS/IOuza99VZE=
-k8s.io/client-go v0.33.1 h1:ZZV/Ks2g92cyxWkRRnfUDsnhNn28eFpt26aGc8KbXF4=
-k8s.io/client-go v0.33.1/go.mod h1:JAsUrl1ArO7uRVFWfcj6kOomSlCv+JpvIsp6usAGefA=
-k8s.io/code-generator v0.33.1 h1:ZLzIRdMsh3Myfnx9BaooX6iQry29UJjVfVG+BuS+UMw=
-k8s.io/code-generator v0.33.1/go.mod h1:HUKT7Ubp6bOgIbbaPIs9lpd2Q02uqkMCMx9/GjDrWpY=
-k8s.io/component-base v0.33.1 h1:EoJ0xA+wr77T+G8p6T3l4efT2oNwbqBVKR71E0tBIaI=
-k8s.io/component-base v0.33.1/go.mod h1:guT/w/6piyPfTgq7gfvgetyXMIh10zuXA6cRRm3rDuY=
-k8s.io/component-helpers v0.33.1 h1:DdQMww8jOr+sGhIrkz70Lp9Qerq/JzeZDBRd508DHDo=
-k8s.io/component-helpers v0.33.1/go.mod h1:LQwxW5L3dH7341Unj+phndJu0Ic5UjxA//7FT8YVP5U=
+k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY=
+k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs=
+k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8=
+k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8=
+k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY=
+k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
+k8s.io/apiserver v0.33.2 h1:KGTRbxn2wJagJowo29kKBp4TchpO1DRO3g+dB/KOJN4=
+k8s.io/apiserver v0.33.2/go.mod h1:9qday04wEAMLPWWo9AwqCZSiIn3OYSZacDyu/AcoM/M=
+k8s.io/cli-runtime v0.33.2 h1:koNYQKSDdq5AExa/RDudXMhhtFasEg48KLS2KSAU74Y=
+k8s.io/cli-runtime v0.33.2/go.mod h1:gnhsAWpovqf1Zj5YRRBBU7PFsRc6NkEkwYNQE+mXL88=
+k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E=
+k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo=
+k8s.io/code-generator v0.33.2 h1:PCJ0Y6viTCxxJHMOyGqYwWEteM4q6y1Hqo2rNpl6jF4=
+k8s.io/code-generator v0.33.2/go.mod h1:hBjCA9kPMpjLWwxcr75ReaQfFXY8u+9bEJJ7kRw3J8c=
+k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0=
+k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k=
+k8s.io/component-helpers v0.33.2 h1:AjCtYzst11NV8ensxV/2LEEXRwctqS7Bs44bje9Qcnw=
+k8s.io/component-helpers v0.33.2/go.mod h1:PsPpiCk74n8pGWp1d6kjK/iSKBTyQfIacv02BNkMenU=
 k8s.io/controller-manager v0.32.1 h1:z3oQp1O5l0cSzM/MKf8V4olhJ9TmnELoJRPcV/v1s+Y=
 k8s.io/controller-manager v0.32.1/go.mod h1:dVA1UZPbqHH4hEhrrnLvQ4d5qVQCklNB8GEzYV59v/4=
 k8s.io/gengo v0.0.0-20250130153323-76c5745d3511 h1:4eL6zr5VCj71nu2nOuQ6j6m/kqh5WueXBN8daZkNe90=
@@ -2120,8 +2120,8 @@ 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-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
 k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
-k8s.io/kubectl v0.33.1 h1:OJUXa6FV5bap6iRy345ezEjU9dTLxqv1zFTVqmeHb6A=
-k8s.io/kubectl v0.33.1/go.mod h1:Z07pGqXoP4NgITlPRrnmiM3qnoo1QrK1zjw85Aiz8J0=
+k8s.io/kubectl v0.33.2 h1:7XKZ6DYCklu5MZQzJe+CkCjoGZwD1wWl7t/FxzhMz7Y=
+k8s.io/kubectl v0.33.2/go.mod h1:8rC67FB8tVTYraovAGNi/idWIK90z2CHFNMmGJZJ3KI=
 k8s.io/kubernetes v1.33.2 h1:Vk3hsCaazyMQ6CXhu029AEPlBoYsEnD8oEIC0bP2pWQ=
 k8s.io/kubernetes v1.33.2/go.mod h1:nrt8sldmckKz2fCZhgRX3SKfS2e+CzXATPv6ITNkU00=
 k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 h1:jgJW5IePPXLGB8e/1wvd0Ich9QE97RvvF3a8J3fP/Lg=
diff --git a/internal/cmd/agent/deployer/summary/doc.go b/internal/cmd/agent/deployer/summary/doc.go
new file mode 100644
index 00000000..ae230cdf
--- /dev/null
+++ b/internal/cmd/agent/deployer/summary/doc.go
@@ -0,0 +1,3 @@
+// Package imported from github.com/rancher/wrangler, with a few local tweaks (e.g. status messages deduplication)
+// Current version: v3.2.1
+package summary
diff --git a/internal/cmd/agent/deployer/summary/gvk.go b/internal/cmd/agent/deployer/summary/gvk.go
new file mode 100644
index 00000000..b177f97f
--- /dev/null
+++ b/internal/cmd/agent/deployer/summary/gvk.go
@@ -0,0 +1,86 @@
+package summary
+
+import (
+	"encoding/json"
+	"fmt"
+	"regexp"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/util/sets"
+)
+
+var (
+	gvkRegExp = regexp.MustCompile(`^(.*?)/(.*),\s*Kind=(.+)$`)
+)
+
+// conditionTypeStatusJSON is a custom JSON to map a complex object into a standard JSON object. It maps Groups,
+// Versions and Kinds to Conditions, Types, and Status, indicating with a flag if a certain condition with specific
+// status represents an error or not. It is expected to be something like:
+//
+//	{
+//		"gvk": 			"helm.cattle.io/v1, Kind=HelmChart",
+//		"conditionMappings": [
+//			{
+//				"type": "JobCreated"	// This means JobCreated is mostly informational and True or False
+//			},				// doesn't mean error
+//			{
+//				"type": "Failed",	// This means Failed is considered error if it's status is True
+//				"status": ["True"]
+//			},
+//		}
+//	}
+type conditionTypeStatusJSON struct {
+	GVK               string                     `json:"gvk"`
+	ConditionMappings []conditionStatusErrorJSON `json:"conditionMappings"`
+}
+
+type conditionStatusErrorJSON struct {
+	Type   string                   `json:"type"`
+	Status []metav1.ConditionStatus `json:"status,omitempty"`
+}
+
+type ConditionTypeStatusErrorMapping map[schema.GroupVersionKind]map[string]sets.Set[metav1.ConditionStatus]
+
+func (m ConditionTypeStatusErrorMapping) MarshalJSON() ([]byte, error) {
+	output := []conditionTypeStatusJSON{}
+	for gvk, mapping := range m {
+		typeStatus := conditionTypeStatusJSON{GVK: gvk.String()}
+		for condition, statuses := range mapping {
+			typeStatus.ConditionMappings = append(typeStatus.ConditionMappings, conditionStatusErrorJSON{
+				Type:   condition,
+				Status: statuses.UnsortedList(),
+			})
+		}
+		output = append(output, typeStatus)
+	}
+	return json.Marshal(output)
+}
+
+func (m ConditionTypeStatusErrorMapping) UnmarshalJSON(data []byte) error {
+	var conditionMappingsJSON []conditionTypeStatusJSON
+	err := json.Unmarshal(data, &conditionMappingsJSON)
+	if err != nil {
+		return err
+	}
+
+	for _, mapping := range conditionMappingsJSON {
+		// checking if mapping.GVK is in the right format: <group>/[version], Kind=[kind]
+		mx := gvkRegExp.FindStringSubmatch(mapping.GVK)
+		if len(mx) == 0 {
+			return fmt.Errorf("gvk parsing failed: wrong GVK format: <%s>", mapping.GVK)
+		}
+
+		conditionMappings := map[string]sets.Set[metav1.ConditionStatus]{}
+		for _, condition := range mapping.ConditionMappings {
+			conditionMappings[condition.Type] = sets.New[metav1.ConditionStatus](condition.Status...)
+		}
+
+		m[schema.GroupVersionKind{
+			Group:   mx[1],
+			Version: mx[2],
+			Kind:    mx[3],
+		}] = conditionMappings
+	}
+	return nil
+}
diff --git a/internal/cmd/agent/deployer/summary/summarizers.go b/internal/cmd/agent/deployer/summary/summarizers.go
index f27af67b..ed10487e 100644
--- a/internal/cmd/agent/deployer/summary/summarizers.go
+++ b/internal/cmd/agent/deployer/summary/summarizers.go
@@ -1,6 +1,8 @@
 package summary
 
 import (
+	"encoding/json"
+	"os"
 	"strings"
 	"time"
 
@@ -9,13 +11,19 @@ import (
 	"github.com/rancher/fleet/internal/cmd/agent/deployer/kv"
 	fleetv1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1/summary"
 
+	"github.com/sirupsen/logrus"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/util/sets"
 	kstatus "sigs.k8s.io/cli-utils/pkg/kstatus/status"
 )
 
 const (
-	kindSep = ", Kind="
-	reason  = "%REASON%"
+	kindSep                    = ", Kind="
+	reason                     = "%REASON%"
+	checkGVKErrorMappingEnvVar = "CATTLE_WRANGLER_CHECK_GVK_ERROR_MAPPING"
 )
 
 var (
@@ -63,25 +71,36 @@ var (
 		"Processed":                   "processed",
 	}
 
-	// True == error
-	// False ==
-	// Unknown ==
-	ErrorTrue = map[string]bool{
-		"OutOfDisk":           true,
-		"MemoryPressure":      true,
-		"DiskPressure":        true,
-		"NetworkUnavailable":  true,
-		"KernelHasNoDeadlock": true,
-		"Unschedulable":       true,
-		"ReplicaFailure":      true,
-		"Stalled":             true,
-	}
-
-	// True ==
-	// False == error
-	// Unknown ==
-	ErrorFalse = map[string]bool{
-		"Failed": true,
+	// For given GVK, This condition Type and this Status, indicates an error or not
+	// e.g.: GVK: helm.cattle.io/v1, HelmChart
+	//		--> JobCreated: [], indicates True or False are not errors
+	//		--> Failed: ["True"], indicates "True" status is considered error
+	//		--> Worked: ["False"], indicates "False" status is considered error
+	//		--> Unknown: ["True", "False"] indicated "True" or "False" are considered errors
+	GVKConditionErrorMapping = ConditionTypeStatusErrorMapping{
+		{Group: "helm.cattle.io", Version: "v1", Kind: "HelmChart"}: {
+			"JobCreated": sets.New[metav1.ConditionStatus](),
+			"Failed":     sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+		},
+		{Group: "", Version: "v1", Kind: "Node"}: {
+			"OutOfDisk":          sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+			"MemoryPressure":     sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+			"DiskPressure":       sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+			"NetworkUnavailable": sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+		},
+		{Group: "apps", Version: "v1", Kind: "Deployment"}: {
+			"ReplicaFailure": sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+			"Progressing":    sets.New[metav1.ConditionStatus](metav1.ConditionFalse),
+		},
+		{Group: "apps", Version: "v1", Kind: "ReplicaSet"}: {
+			"ReplicaFailure": sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+		},
+
+		// FALLBACK: In case we cannot match any Groups, Versions and Kinds then we fallback to this mapping.
+		{Group: "", Version: "", Kind: ""}: {
+			"Stalled": sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+			"Failed":  sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+		},
 	}
 
 	// True ==
@@ -127,6 +146,57 @@ func init() {
 		checkCattleTypes,
 		kStatusSummarizer,
 	}
+
+	initializeCheckErrors()
+}
+
+func initializeCheckErrors() {
+	gvkConfig := os.Getenv(checkGVKErrorMappingEnvVar)
+	if gvkConfig != "" {
+		logrus.Debugf("GVK Error Mapping Provided")
+		gvkErrorMapping := ConditionTypeStatusErrorMapping{}
+		if err := json.Unmarshal([]byte(gvkConfig), &gvkErrorMapping); err != nil {
+			logrus.Errorln("Unable to parse GVK config: ", err.Error())
+			return
+		}
+
+		// Merging GVK + Conditions
+		//
+		// IMPORTANT: In case you add a condition that exists already, we replace the set that holds the Status
+		// completely of that condition by yours, this makes it possible to deactivate certain statuses for
+		// debugging reasons.
+		//
+		// eg.:
+		//
+		// Existing one:
+		//
+		// helm.cattle.io, Kind=HelmChart
+		// JobCreated => []
+		// Failed => ["True"]
+		//
+		// In case you set Failed = ["False"] and add Ready = ["False"]:
+		//
+		// helm.cattle.io, Kind=HelmChart
+		// JobCreated => []			<<<= not changed
+		// Failed => ["False"]		<<<= replaced completely the set.
+		// Ready => ["False"] 		<<<= merged to existing conditions.
+		//
+		// So, we've merged the conditions, but not the status set values.
+		for gvk, newConditionsMap := range gvkErrorMapping {
+			if _, exists := GVKConditionErrorMapping[gvk]; !exists {
+				GVKConditionErrorMapping[gvk] = map[string]sets.Set[metav1.ConditionStatus]{}
+			}
+
+			existingConditionsMap := GVKConditionErrorMapping[gvk]
+			for condition, errorMapping := range newConditionsMap {
+				existingConditionsMap[condition] = errorMapping
+			}
+			GVKConditionErrorMapping[gvk] = existingConditionsMap
+		}
+		logrus.Debugf("GVK Error Mapping Set")
+		return
+	}
+	logrus.Debugf("GVK Error Mapping not provided, using predefined values")
 }
 
 func checkOwner(obj data.Object, conditions []Condition, summary fleetv1.Summary) fleetv1.Summary {
@@ -213,28 +283,37 @@ func checkStandard(obj data.Object, _ []Condition, summary fleetv1.Summary) flee
 	return summary
 }
 
-func checkErrors(_ data.Object, conditions []Condition, summary fleetv1.Summary) fleetv1.Summary {
-	for _, c := range conditions {
-		if (ErrorFalse[c.Type()] && c.Status() == "False") || c.Reason() == "Error" {
-			summary.Error = true
-			summary.Message = append(summary.Message, c.Message())
-			if summary.State == "active" || summary.State == "" {
-				summary.State = "error"
-			}
-			break
-		}
+func checkErrors(data data.Object, conditions []Condition, summary fleetv1.Summary) fleetv1.Summary {
+	if len(conditions) == 0 {
+		return summary
 	}
 
-	if summary.Error {
-		return summary
+	ustr := &unstructured.Unstructured{
+		Object: data,
+	}
+
+	conditionMapping, found := GVKConditionErrorMapping[ustr.GroupVersionKind()]
+	if !found {
+		conditionMapping = GVKConditionErrorMapping[schema.GroupVersionKind{}]
 	}
 
 	for _, c := range conditions {
-		if ErrorTrue[c.Type()] && c.Status() == "True" {
+		status, found := conditionMapping[c.Type()]
+		reasonIsError := c.Reason() == "Error"
+
+		if !found && !reasonIsError {
+			continue
+		}
+
+		if reasonIsError || status.Has(metav1.ConditionStatus(c.Status())) {
 			summary.Error = true
 			summary.Message = append(summary.Message, c.Message())
+			if summary.State == "active" || summary.State == "" {
+				summary.State = "error"
+			}
 		}
 	}
+
 	return summary
 }
 
diff --git a/internal/cmd/controller/reconciler/cluster_controller.go b/internal/cmd/controller/reconciler/cluster_controller.go
index 01d89a66..b692375b 100644
--- a/internal/cmd/controller/reconciler/cluster_controller.go
+++ b/internal/cmd/controller/reconciler/cluster_controller.go
@@ -167,9 +167,11 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 		return false
 	})
 
-	// Count the number of gitrepo, bundledeployemt and deployed resources for this cluster
+	// Count the number of gitrepos, helmOps, bundledeployment and deployed resources for this cluster
 	cluster.Status.DesiredReadyGitRepos = 0
 	cluster.Status.ReadyGitRepos = 0
+	cluster.Status.DesiredReadyHelmOps = 0
+	cluster.Status.ReadyHelmOps = 0
 	cluster.Status.ResourceCounts = fleet.ResourceCounts{}
 	cluster.Status.Summary = fleet.BundleSummary{}
 
@@ -179,23 +181,37 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 
 	resourcestatus.SetClusterResources(bundleDeployments, cluster)
 
-	repos := map[types.NamespacedName]bool{}
+	gitRepos := map[types.NamespacedName]bool{}
+	helmOps := map[types.NamespacedName]bool{}
 	for _, bd := range bundleDeployments {
 		state := summary.GetDeploymentState(&bd)
 		summary.IncrementState(&cluster.Status.Summary, bd.Name, state, summary.MessageFromDeployment(&bd), bd.Status.ModifiedStatus, bd.Status.NonReadyStatus)
 		cluster.Status.Summary.DesiredReady++
 
-		repoNamespace, repoName := bd.Labels[fleet.BundleNamespaceLabel], bd.Labels[fleet.RepoLabel]
-		if repoNamespace != "" && repoName != "" {
+		ns := bd.Labels[fleet.BundleNamespaceLabel]
+
+		repoName := bd.Labels[fleet.RepoLabel]
+		helmOpName := bd.Labels[fleet.HelmOpLabel]
+
+		if ns == "" {
+			continue
+		}
+
+		switch { // if both labels are set (which should never happen), the bundle deployment will be counted as gitOps
+		case repoName != "":
 			// a gitrepo is ready if its bundledeployments are ready, take previous state into account
-			repoKey := types.NamespacedName{Namespace: repoNamespace, Name: repoName}
-			repos[repoKey] = (state == fleet.Ready) || repos[repoKey]
+			repoKey := types.NamespacedName{Namespace: ns, Name: repoName}
+			gitRepos[repoKey] = (state == fleet.Ready) || gitRepos[repoKey]
+		case helmOpName != "":
+			// a helmOp is ready if its bundledeployments are ready, take previous state into account
+			helmOpKey := types.NamespacedName{Namespace: ns, Name: helmOpName}
+			helmOps[helmOpKey] = (state == fleet.Ready) || helmOps[helmOpKey]
 		}
 	}
 
 	// a cluster is ready if all its gitrepos are ready and the resources are ready too
 	allReady := true
-	for repo, ready := range repos {
+	for repo, ready := range gitRepos {
 		gitrepo := &fleet.GitRepo{}
 		if err := r.Get(ctx, repo, gitrepo); err == nil {
 			cluster.Status.DesiredReadyGitRepos++
@@ -207,6 +223,18 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 		}
 	}
 
+	for key, ready := range helmOps {
+		helmOp := &fleet.HelmOp{}
+		if err := r.Get(ctx, key, helmOp); err == nil {
+			cluster.Status.DesiredReadyHelmOps++
+			if ready {
+				cluster.Status.ReadyHelmOps++
+			} else {
+				allReady = false
+			}
+		}
+	}
+
 	summary.SetReadyConditions(&cluster.Status, "Bundle", cluster.Status.Summary)
 
 	// Calculate display status fields
@@ -236,7 +264,7 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 	}
 
 	if allReady && cluster.Status.ResourceCounts.Ready != cluster.Status.ResourceCounts.DesiredReady {
-		logger.V(1).Info("Cluster is not ready, because not all gitrepos are ready",
+		logger.V(1).Info("Cluster is not ready, because not all gitrepos/helmOps are ready",
 			"namespace", cluster.Namespace,
 			"name", cluster.Name,
 			"ready", cluster.Status.ResourceCounts.Ready,
diff --git a/internal/metrics/cluster_metrics.go b/internal/metrics/cluster_metrics.go
index ce9e4b4e..57c5f3cd 100644
--- a/internal/metrics/cluster_metrics.go
+++ b/internal/metrics/cluster_metrics.go
@@ -56,6 +56,24 @@ var (
 			},
 			clusterLabels,
 		),
+		"desired_ready_helm_ops": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "desired_ready_helm_ops",
+				Help:      "The desired number of HelmOps to be in a ready state.",
+			},
+			clusterLabels,
+		),
+		"ready_helm_ops": promauto.NewGaugeVec(
+			prometheus.GaugeOpts{
+				Namespace: metricPrefix,
+				Subsystem: clusterSubsystem,
+				Name:      "ready_helm_ops",
+				Help:      "The number of HelmOps in a ready state.",
+			},
+			clusterLabels,
+		),
 		"resources_count_desiredready": promauto.NewGaugeVec(
 			prometheus.GaugeOpts{
 				Namespace: metricPrefix,
@@ -159,6 +177,10 @@ func collectClusterMetrics(obj any, metrics map[string]prometheus.Collector) {
 		With(labels).Set(float64(cluster.Status.DesiredReadyGitRepos))
 	metrics["ready_git_repos"].(*prometheus.GaugeVec).
 		With(labels).Set(float64(cluster.Status.ReadyGitRepos))
+	metrics["desired_ready_helm_ops"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.DesiredReadyHelmOps))
+	metrics["ready_helm_ops"].(*prometheus.GaugeVec).
+		With(labels).Set(float64(cluster.Status.ReadyHelmOps))
 	metrics["resources_count_desiredready"].(*prometheus.GaugeVec).
 		With(labels).Set(float64(cluster.Status.ResourceCounts.DesiredReady))
 	metrics["resources_count_missing"].(*prometheus.GaugeVec).
diff --git a/package/Dockerfile b/package/Dockerfile
index 1f6a1fb8..94358a83 100644
--- a/package/Dockerfile
+++ b/package/Dockerfile
@@ -27,6 +27,7 @@ ONBUILD COPY fleetcontroller-linux-$ARCH /usr/bin/fleetcontroller
 ONBUILD COPY fleet-linux-$ARCH /usr/bin/fleet
 
 FROM copy_${BUILD_ENV}
+RUN useradd -u 1000 user
 USER 1000
 ENTRYPOINT ["tini", "--"]
 CMD ["fleetcontroller"]
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/cluster_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/cluster_types.go
index 90dd919f..b2baf7ed 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/cluster_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/cluster_types.go
@@ -148,14 +148,25 @@ type ClusterStatus struct {
 	Summary BundleSummary `json:"summary,omitempty"`
 	// ResourceCounts is an aggregate over the ResourceCounts.
 	ResourceCounts ResourceCounts `json:"resourceCounts,omitempty"`
+
 	// ReadyGitRepos is the number of gitrepos for this cluster that are ready.
 	// +optional
 	ReadyGitRepos int `json:"readyGitRepos"`
+
 	// DesiredReadyGitRepos is the number of gitrepos for this cluster that
 	// are desired to be ready.
 	// +optional
 	DesiredReadyGitRepos int `json:"desiredReadyGitRepos"`
 
+	// ReadyHelmOps is the number of helmop resources for this cluster that are ready.
+	// +optional
+	ReadyHelmOps int `json:"readyHelmOps"`
+
+	// DesiredReadyHelmOps is the number of helmop resources for this cluster that
+	// are desired to be ready.
+	// +optional
+	DesiredReadyHelmOps int `json:"desiredReadyHelmOps"`
+
 	// AgentEnvVarsHash is a hash of the agent's env vars, used to detect changes.
 	// +nullable
 	AgentEnvVarsHash string `json:"agentEnvVarsHash,omitempty"`
diff --git a/pkg/apis/go.mod b/pkg/apis/go.mod
index a86ee039..cdcecb85 100644
--- a/pkg/apis/go.mod
+++ b/pkg/apis/go.mod
@@ -2,7 +2,7 @@ module github.com/rancher/fleet/pkg/apis
 
 go 1.24.0
 
-toolchain go1.24.4
+toolchain go1.24.5
 
 require (
 	github.com/rancher/wrangler/v3 v3.2.1



Test patch:
diff --git a/e2e/metrics/cluster_test.go b/e2e/metrics/cluster_test.go
index c47a60a6..63ff88c8 100644
--- a/e2e/metrics/cluster_test.go
+++ b/e2e/metrics/cluster_test.go
@@ -18,6 +18,8 @@ var (
 	expectedMetrics = MetricsSelector{
 		"fleet_cluster_desired_ready_git_repos":      {},
 		"fleet_cluster_ready_git_repos":              {},
+		"fleet_cluster_desired_ready_helm_ops":       {},
+		"fleet_cluster_ready_helm_ops":               {},
 		"fleet_cluster_resources_count_desiredready": {},
 		"fleet_cluster_resources_count_missing":      {},
 		"fleet_cluster_resources_count_modified":     {},
diff --git a/e2e/metrics/suite_test.go b/e2e/metrics/suite_test.go
index 880d56f7..8427c389 100644
--- a/e2e/metrics/suite_test.go
+++ b/e2e/metrics/suite_test.go
@@ -91,14 +91,16 @@ func setupLoadBalancer(shard string, app string) (metricsURL string) {
 	if ip := os.Getenv("external_ip"); ip != "" {
 		metricsURL = fmt.Sprintf("http://%s:%d/metrics", ip, port)
 	} else {
-		Eventually(func() (string, error) {
+		Eventually(func(g Gomega) {
 			ip, err := ks.Get(
 				"service", loadBalancerName,
 				"-o", "jsonpath={.status.loadBalancer.ingress[0].ip}",
 			)
+			g.Expect(err).ToNot(HaveOccurred())
+			g.Expect(ip).ToNot(BeEmpty())
+
 			metricsURL = fmt.Sprintf("http://%s:%d/metrics", ip, port)
-			return ip, err
-		}).ShouldNot(BeEmpty())
+		}).Should(Succeed())
 	}
 
 	DeferCleanup(func() {
diff --git a/integrationtests/controller/cluster/status_test.go b/integrationtests/controller/cluster/status_test.go
index f4bfd0f7..9d923120 100644
--- a/integrationtests/controller/cluster/status_test.go
+++ b/integrationtests/controller/cluster/status_test.go
@@ -1,6 +1,8 @@
 package cluster
 
 import (
+	"fmt"
+
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 
@@ -27,7 +29,7 @@ var _ = Describe("Cluster Status Fields", func() {
 		})
 	})
 
-	When("Bundledeployment is added", func() {
+	When("Bundledeployments are added", func() {
 		BeforeEach(func() {
 			cluster, err := utils.CreateCluster(ctx, k8sClient, "cluster", namespace, nil, namespace)
 			Expect(err).NotTo(HaveOccurred())
@@ -39,12 +41,30 @@ var _ = Describe("Cluster Status Fields", func() {
 					Namespace: namespace,
 				},
 				Spec: v1alpha1.GitRepoSpec{
-					Repo: "https://github.com/rancher/fleet-test-data/not-found",
+					Repo: "https://github.com/rancher/fleet-test-data/does-not-matter",
 				},
 			}
 			err = k8sClient.Create(ctx, gitrepo)
 			Expect(err).NotTo(HaveOccurred())
 
+			helmop := &v1alpha1.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-helmop",
+					Namespace: namespace,
+				},
+				Spec: v1alpha1.HelmOpSpec{
+					BundleSpec: v1alpha1.BundleSpec{
+						BundleDeploymentOptions: v1alpha1.BundleDeploymentOptions{
+							Helm: &v1alpha1.HelmOptions{
+								Chart: "https://github.com/rancher/fleet-test-data/does-not-matter.tgz",
+							},
+						},
+					},
+				},
+			}
+			err = k8sClient.Create(ctx, helmop)
+			Expect(err).NotTo(HaveOccurred())
+
 			targets := []v1alpha1.BundleTarget{
 				{
 					BundleDeploymentOptions: v1alpha1.BundleDeploymentOptions{
@@ -54,9 +74,13 @@ var _ = Describe("Cluster Status Fields", func() {
 					ClusterName: "cluster",
 				},
 			}
-			bundle, err := utils.CreateBundle(ctx, k8sClient, "name", namespace, targets, targets)
+			gitBundle, err := utils.CreateBundle(ctx, k8sClient, "gitrepo-bundle", namespace, targets, targets)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(gitBundle).To(Not(BeNil()))
+
+			helmBundle, err := utils.CreateBundle(ctx, k8sClient, "helmop-bundle", namespace, targets, targets)
 			Expect(err).NotTo(HaveOccurred())
-			Expect(bundle).To(Not(BeNil()))
+			Expect(helmBundle).To(Not(BeNil()))
 		})
 
 		It("updates the status fields", func() {
@@ -69,24 +93,38 @@ var _ = Describe("Cluster Status Fields", func() {
 			}).Should(BeTrue())
 			Expect(cluster.Status.Summary.Ready).To(Equal(0))
 
-			bundle := &v1alpha1.Bundle{}
-			err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "name"}, bundle)
+			gBundle := &v1alpha1.Bundle{}
+			err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "gitrepo-bundle"}, gBundle)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(gBundle).To(Not(BeNil()))
+			Expect(gBundle.Status.Display.State).To(Equal(""))
+
+			hBundle := &v1alpha1.Bundle{}
+			err = k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "helmop-bundle"}, hBundle)
 			Expect(err).NotTo(HaveOccurred())
-			Expect(bundle).To(Not(BeNil()))
-			Expect(bundle.Status.Display.State).To(Equal(""))
+			Expect(hBundle).To(Not(BeNil()))
+			Expect(hBundle.Status.Display.State).To(Equal(""))
+
+			By("Adding the gitrepo name to the git bundle")
+			Eventually(func() error {
+				err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "gitrepo-bundle"}, gBundle)
+				Expect(err).ToNot(HaveOccurred())
+				gBundle.Labels["fleet.cattle.io/repo-name"] = "test-gitrepo"
+				return k8sClient.Update(ctx, gBundle)
+			}).ShouldNot(HaveOccurred())
 
-			By("Add the gitrepo name to bundle")
+			By("Adding the helmop name to the Helm bundle")
 			Eventually(func() error {
-				err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "name"}, bundle)
+				err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "helmop-bundle"}, hBundle)
 				Expect(err).ToNot(HaveOccurred())
-				bundle.Labels["fleet.cattle.io/repo-name"] = "test-gitrepo"
-				return k8sClient.Update(ctx, bundle)
+				hBundle.Labels["fleet.cattle.io/fleet-helm-name"] = "test-helmop"
+				return k8sClient.Update(ctx, hBundle)
 			}).ShouldNot(HaveOccurred())
 
-			By("Prepare bundledeployment so the bundle gets to 'Ready' state")
+			By("Preparing the bundledeployments so the bundles get to 'Ready' state")
 			bd := &v1alpha1.BundleDeployment{}
 			Eventually(func() error {
-				err = k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "name"}, bd)
+				err = k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "gitrepo-bundle"}, bd)
 				if err != nil {
 					return err
 				}
@@ -98,7 +136,7 @@ var _ = Describe("Cluster Status Fields", func() {
 			}).ShouldNot(HaveOccurred())
 
 			Eventually(func() error {
-				err = k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "name"}, bd)
+				err = k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "gitrepo-bundle"}, bd)
 				if err != nil {
 					return err
 				}
@@ -106,15 +144,53 @@ var _ = Describe("Cluster Status Fields", func() {
 				return k8sClient.Update(ctx, bd)
 			}).ShouldNot(HaveOccurred())
 
+			bd = &v1alpha1.BundleDeployment{}
+			Eventually(func() error {
+				err = k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "helmop-bundle"}, bd)
+				if err != nil {
+					return err
+				}
+				bd.Status.Display.State = "Ready"
+				bd.Status.AppliedDeploymentID = bd.Spec.DeploymentID
+				bd.Status.Ready = true
+				bd.Status.NonModified = true
+				return k8sClient.Status().Update(ctx, bd)
+			}).ShouldNot(HaveOccurred())
+
+			Eventually(func() error {
+				err = k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "helmop-bundle"}, bd)
+				if err != nil {
+					return err
+				}
+				bd.Labels["fleet.cattle.io/fleet-helm-name"] = "test-helmop"
+				return k8sClient.Update(ctx, bd)
+			}).ShouldNot(HaveOccurred())
+
 			cluster = &v1alpha1.Cluster{}
-			Eventually(func() bool {
+			Eventually(func(g Gomega) {
 				err := k8sClient.Get(ctx, types.NamespacedName{Name: "cluster", Namespace: namespace}, cluster)
-				Expect(err).NotTo(HaveOccurred())
-
-				return cluster.Status.Summary.DesiredReady == 1 && cluster.Status.ReadyGitRepos == 1
-			}).Should(BeTrue())
-			Expect(cluster.Status.Display.ReadyBundles).To(Equal("1/1"))
-			Expect(cluster.Status.Summary.Ready).To(Equal(1))
+				g.Expect(err).NotTo(HaveOccurred())
+
+				g.Expect(cluster.Status.Summary.DesiredReady).To(Equal(2))
+				g.Expect(cluster.Status.DesiredReadyGitRepos).To(
+					Equal(1),
+					fmt.Sprintf("got %d desired ready GitRepos, expected 1", cluster.Status.DesiredReadyGitRepos),
+				)
+				g.Expect(cluster.Status.ReadyGitRepos).To(
+					Equal(1),
+					fmt.Sprintf("got %d ready GitRepos, expected 1", cluster.Status.ReadyGitRepos),
+				)
+				g.Expect(cluster.Status.DesiredReadyHelmOps).To(
+					Equal(1),
+					fmt.Sprintf("got %d desired ready HelmOps, expected 1", cluster.Status.DesiredReadyHelmOps),
+				)
+				g.Expect(cluster.Status.ReadyHelmOps).To(
+					Equal(1),
+					fmt.Sprintf("got %d ready HelmOps, expected 1", cluster.Status.ReadyHelmOps),
+				)
+			}).Should(Succeed())
+			Expect(cluster.Status.Display.ReadyBundles).To(Equal("2/2"))
+			Expect(cluster.Status.Summary.Ready).To(Equal(2))
 		})
 	})
 })
diff --git a/internal/bundlereader/loaddirectory_test.go b/internal/bundlereader/loaddirectory_test.go
index 257b2f6d..f2abd203 100644
--- a/internal/bundlereader/loaddirectory_test.go
+++ b/internal/bundlereader/loaddirectory_test.go
@@ -3,6 +3,8 @@ package bundlereader_test
 import (
 	"context"
 	"io"
+	"net/http"
+	"net/http/httptest"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -597,6 +599,59 @@ func TestGetContent(t *testing.T) {
 	}
 }
 
+type authTester struct {
+	t    *testing.T
+	want string
+}
+
+func (a *authTester) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+	url, err := r.URL.Parse(r.URL.String())
+	if err != nil {
+		http.Error(w, "Invalid URL", http.StatusBadRequest)
+		return
+	}
+	if sskey := url.Query().Get("sshkey"); sskey != a.want {
+		a.t.Errorf("wrong or no sshkey query parameter: want %s but got %s", a.want, sskey)
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+func TestGetContentSSHKey(t *testing.T) {
+	cases := []struct {
+		name, want string
+		auth       bundlereader.Auth
+	}{
+		{
+			name: "any URL with SSHPrivateKey set should be queried with sshkey query parameter",
+			auth: bundlereader.Auth{
+				SSHPrivateKey: []byte("foo"),
+			},
+			want: "Zm9v", // base64 encoding of "foo"
+		},
+		{
+			name: "no query parameter if SSHPrivateKey is not set",
+			auth: bundlereader.Auth{},
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			authTester := &authTester{t: t, want: c.want}
+			s := httptest.NewServer(authTester)
+			defer s.Close()
+
+			base, err := os.MkdirTemp("", "test-fleet")
+			require.NoError(t, err)
+			defer os.RemoveAll(base)
+
+			_, _ = bundlereader.GetContent(context.Background(), base, s.URL, "", c.auth, false, []string{})
+		})
+	}
+}
+
 func TestGetContentOCI(t *testing.T) {
 	cases := []struct {
 		name    string
diff --git a/internal/cmd/agent/deployer/summary/gvk_test.go b/internal/cmd/agent/deployer/summary/gvk_test.go
new file mode 100644
index 00000000..d61837ea
--- /dev/null
+++ b/internal/cmd/agent/deployer/summary/gvk_test.go
@@ -0,0 +1,184 @@
+package summary
+
+import (
+	"encoding/json"
+	"sort"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/sets"
+)
+
+func TestConditionalTypeStatusErrorMapping_MarshalJSON(t *testing.T) {
+	testCases := []struct {
+		name        string
+		input       ConditionTypeStatusErrorMapping
+		expected    []byte
+		expectedErr error
+	}{
+		{
+			name: "usual case",
+			input: ConditionTypeStatusErrorMapping{
+				{Group: "helm.cattle.io", Version: "v1", Kind: "HelmChart"}: {
+					"JobCreated": sets.New[metav1.ConditionStatus](),
+					"Failed":     sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+				},
+			},
+			expected: []byte(`
+			[
+				{
+					"gvk": "helm.cattle.io/v1, Kind=HelmChart",
+					"conditionMappings": [
+						{
+							"type": "JobCreated"
+						},
+						{
+							"type": "Failed",
+							"status": ["True"]
+						}
+					]
+				}
+			]
+			`),
+			expectedErr: nil,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			output, err := json.Marshal(&tc.input)
+
+			if tc.expectedErr == nil {
+				assert.NoError(t, err)
+			} else {
+				assert.Error(t, err)
+			}
+
+			actual := []conditionTypeStatusJSON{}
+			expected := []conditionTypeStatusJSON{}
+			assert.NoError(t, json.Unmarshal(output, &actual))
+			assert.NoError(t, json.Unmarshal(tc.expected, &expected))
+
+			sort.Slice(actual, func(i, j int) bool { return actual[i].GVK < actual[j].GVK })
+			sort.Slice(expected, func(i, j int) bool { return expected[i].GVK < expected[j].GVK })
+
+			for _, act := range actual {
+				sort.Slice(act.ConditionMappings, func(i, j int) bool {
+					return act.ConditionMappings[i].Type < act.ConditionMappings[j].Type
+				})
+
+				for _, mappings := range act.ConditionMappings {
+					sort.Slice(mappings.Status, func(i, j int) bool {
+						return mappings.Status[i] < mappings.Status[j]
+					})
+				}
+			}
+
+			for _, exp := range expected {
+				sort.Slice(exp.ConditionMappings, func(i, j int) bool {
+					return exp.ConditionMappings[i].Type < exp.ConditionMappings[j].Type
+				})
+
+				for _, mappings := range exp.ConditionMappings {
+					sort.Slice(mappings.Status, func(i, j int) bool {
+						return mappings.Status[i] < mappings.Status[j]
+					})
+				}
+			}
+
+			assert.Equal(t, expected, actual)
+		})
+	}
+}
+
+func TestConditionalTypeStatusErrorMapping_UnmarshalJSON(t *testing.T) {
+	testCases := []struct {
+		name            string
+		input           []byte
+		expected        ConditionTypeStatusErrorMapping
+		errorIsExpected bool
+	}{
+		{
+			name: "usual case",
+			input: []byte(`
+			[
+				{
+					"gvk": "helm.cattle.io/v1, Kind=HelmChart",
+					"conditionMappings": [
+						{
+							"type": "JobCreated"
+						},
+						{
+							"type": "Failed",
+							"status": ["True"]
+						}
+					]
+				}
+			]
+			`),
+			expected: ConditionTypeStatusErrorMapping{
+				{Group: "helm.cattle.io", Version: "v1", Kind: "HelmChart"}: {
+					"JobCreated": sets.New[metav1.ConditionStatus](),
+					"Failed":     sets.New[metav1.ConditionStatus](metav1.ConditionTrue),
+				},
+			},
+			errorIsExpected: false,
+		},
+		{
+			name: "core types (no group)",
+			input: []byte(`
+			[
+				{
+					"gvk": "/v1, Kind=Node",
+					"conditionMappings": [
+						{
+							"type": "Ready",
+							"status": ["False"]
+						}
+					]
+				}
+			]
+			`),
+			expected: ConditionTypeStatusErrorMapping{
+				{Group: "", Version: "v1", Kind: "Node"}: {
+					"Ready": sets.New[metav1.ConditionStatus](metav1.ConditionFalse),
+				},
+			},
+			errorIsExpected: false,
+		},
+		{
+			name: "wrong GVK format",
+			input: []byte(`
+			[
+				{
+					"gvk": "wrong GVK format",
+					"conditionMappings": [
+						{
+							"type": "Ready",
+							"status": ["False"]
+						}
+					]
+				}
+			]
+			`),
+			expected:        ConditionTypeStatusErrorMapping{},
+			errorIsExpected: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			gvkConditionMappings := ConditionTypeStatusErrorMapping{}
+
+			err := json.Unmarshal(tc.input, &gvkConditionMappings)
+			if tc.errorIsExpected {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+
+			assert.Equal(t, tc.expected, gvkConditionMappings)
+		})
+	}
+}
diff --git a/internal/cmd/agent/deployer/summary/summarizers_test.go b/internal/cmd/agent/deployer/summary/summarizers_test.go
index 584f2038..81683397 100644
--- a/internal/cmd/agent/deployer/summary/summarizers_test.go
+++ b/internal/cmd/agent/deployer/summary/summarizers_test.go
@@ -1,13 +1,385 @@
 package summary
 
 import (
-	. "github.com/onsi/ginkgo/v2"
-	. "github.com/onsi/gomega"
+	"os"
+	"testing"
 
 	"github.com/rancher/fleet/internal/cmd/agent/deployer/data"
-	fleetv1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1/summary"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1/summary"
+	"github.com/stretchr/testify/assert"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
 )
 
+func TestCheckErrors(t *testing.T) {
+	type input struct {
+		data       data.Object
+		conditions []Condition
+		summary    fleet.Summary
+	}
+
+	type output struct {
+		summary fleet.Summary
+	}
+
+	testCases := []struct {
+		name           string
+		loadConditions func()
+		input          input
+		expected       output
+	}{
+		{
+			name: "gvk not detected - summary remains the same",
+			input: input{
+				data: data.Object{},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+		},
+		{
+			name: "gvk not found - summary remains the same",
+			input: input{
+				data: data.Object{
+					"APIVersion": "sample.cattle.io/v1",
+					"Kind":       "Sample",
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+		},
+		{
+			name: "gvk found, no conditions provided",
+			input: input{
+				data: data.Object{
+					"APIVersion": "helm.cattle.io/v1",
+					"Kind":       "HelmChart",
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+		},
+		{
+			name: "gvk found, condition not found",
+			input: input{
+				data: data.Object{
+					"APIVersion": "helm.cattle.io/v1",
+					"Kind":       "HelmChart",
+				},
+				conditions: []Condition{
+					newCondition("JobFailed", "True", "", ""),
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+		},
+		{
+			name: "gvk found, condition is error",
+			input: input{
+				data: data.Object{
+					"APIVersion": "helm.cattle.io/v1",
+					"Kind":       "HelmChart",
+				},
+				conditions: []Condition{
+					newCondition("Failed", "True", "", "Helm Install Error"),
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: true,
+					Message: []string{
+						"Helm Install Error",
+					},
+				},
+			},
+		},
+		{
+			name: "gvk found, condition is not an error",
+			input: input{
+				data: data.Object{
+					"APIVersion": "helm.cattle.io/v1",
+					"Kind":       "HelmChart",
+				},
+				conditions: []Condition{
+					newCondition("Failed", "False", "", ""),
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+		},
+		{
+			name: "load conditions - gvk not found",
+			input: input{
+				data: data.Object{
+					"APIVersion": "helm.cattle.io/v1",
+					"Kind":       "HelmChart",
+				},
+				conditions: []Condition{
+					newCondition("Failed", "False", "", ""),
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			loadConditions: func() {
+				os.Setenv(checkGVKErrorMappingEnvVar, `
+					[
+						{
+							"gvk": "sample.cattle.io/v1, Kind=Sample",
+							"conditionMappings": [
+								{
+									"type": "Failed",
+									"status": ["True"]
+								}
+							]
+						}
+					]
+				`)
+			},
+		},
+		{
+			name: "load conditions - gvk found - condition is only informational",
+			input: input{
+				data: data.Object{
+					"APIVersion": "sample.cattle.io/v1",
+					"Kind":       "Sample",
+				},
+				conditions: []Condition{
+					newCondition("Created", "True", "", ""),
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			loadConditions: func() {
+				os.Setenv(checkGVKErrorMappingEnvVar, `
+					[
+						{
+							"gvk": "sample.cattle.io/v1, Kind=Sample",
+							"conditionMappings": [
+								{
+									"type": "Created",
+									"status": []
+								}
+							]
+						}
+					]
+				`)
+			},
+		},
+		{
+			name: "load conditions - gvk found - is not an error",
+			input: input{
+				data: data.Object{
+					"APIVersion": "sample.cattle.io/v1",
+					"Kind":       "Sample",
+				},
+				conditions: []Condition{
+					newCondition("Failed", "False", "", ""),
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			loadConditions: func() {
+				os.Setenv(checkGVKErrorMappingEnvVar, `
+					[
+						{
+							"gvk": "sample.cattle.io/v1, Kind=Sample",
+							"conditionMappings": [
+								{
+									"type": "Failed",
+									"status": ["True"]
+								}
+							]
+						}
+					]
+				`)
+			},
+		},
+		{
+			name: "load conditions - gvk found - is error",
+			input: input{
+				data: data.Object{
+					"APIVersion": "sample.cattle.io/v1",
+					"Kind":       "Sample",
+				},
+				conditions: []Condition{
+					newCondition("Failed", "True", "", "Sample Failure"),
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: true,
+					Message: []string{
+						"Sample Failure",
+					},
+				},
+			},
+			loadConditions: func() {
+				os.Setenv(checkGVKErrorMappingEnvVar, `
+					[
+						{
+							"gvk": "sample.cattle.io/v1, Kind=Sample",
+							"conditionMappings": [
+								{
+									"type": "Failed",
+									"status": ["True"]
+								}
+							]
+						}
+					]
+				`)
+			},
+		},
+		{
+			name: "fallback conditions",
+			input: input{
+				data: data.Object{
+					"APIVersion": "fallback.cattle.io/v1",
+					"Kind":       "Fallback",
+				},
+				conditions: []Condition{
+					newCondition("Failed", "True", "", "Sample Failure"),
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: true,
+					Message: []string{
+						"Sample Failure",
+					},
+				},
+			},
+		},
+		{
+			name: "condition has error at reason field",
+			input: input{
+				data: data.Object{
+					"APIVersion": "sample.cattle.io/v1",
+					"Kind":       "Sample",
+				},
+				conditions: []Condition{
+					newCondition("SampleFailed", "True", "Error", "Error in Reason"),
+				},
+				summary: fleet.Summary{
+					State: "testing",
+					Error: false,
+				},
+			},
+			expected: output{
+				summary: fleet.Summary{
+					State: "testing",
+					Error: true,
+					Message: []string{
+						"Error in Reason",
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.loadConditions != nil {
+				tc.loadConditions()
+			}
+			initializeCheckErrors()
+			summary := checkErrors(tc.input.data, tc.input.conditions, tc.input.summary)
+
+			assert.Equal(t, tc.expected.summary, summary)
+		})
+	}
+
+}
+
+func newCondition(conditionType, status, reason, message string) Condition {
+	return Condition{
+		Object: map[string]interface{}{
+			"type":    conditionType,
+			"status":  status,
+			"reason":  reason,
+			"message": message,
+		},
+	}
+}
+
 var _ = Describe("Summary", func() {
 	When("testing kStatusSummarizer", func() {
 		newObj := func(message string) data.Object {
@@ -26,31 +398,31 @@ var _ = Describe("Summary", func() {
 
 		It("should deduplicate messages", func() {
 			obj := newObj("message1; message2; message1; message1; message2")
-			summary := kStatusSummarizer(obj, nil, fleetv1.Summary{})
-			Expect(summary.Message).To(Equal([]string{"message1; message2"}))
-			Expect(summary.Transitioning).To(BeTrue())
-			Expect(summary.Error).To(BeFalse())
+			smr := kStatusSummarizer(obj, nil, fleet.Summary{})
+			Expect(smr.Message).To(Equal([]string{"message1; message2"}))
+			Expect(smr.Transitioning).To(BeTrue())
+			Expect(smr.Error).To(BeFalse())
 		})
 
 		It("should not alter the message that doesn't contain duplicates", func() {
 			obj := newObj("message1; message2; message3")
-			summary := kStatusSummarizer(obj, nil, fleetv1.Summary{})
-			Expect(summary.Message).To(Equal([]string{"message1; message2; message3"}))
+			smr := kStatusSummarizer(obj, nil, fleet.Summary{})
+			Expect(smr.Message).To(Equal([]string{"message1; message2; message3"}))
 		})
 
 		It("should not deduplicate messages with the wrong separator", func() {
 			separators := []string{", ", " ", " , ", "[", "]", "{", "}"}
 			for _, sep := range separators {
 				obj := newObj("message1" + sep + "message1")
-				summary := kStatusSummarizer(obj, nil, fleetv1.Summary{})
-				Expect(summary.Message).To(Equal([]string{"message1" + sep + "message1"}))
+				smr := kStatusSummarizer(obj, nil, fleet.Summary{})
+				Expect(smr.Message).To(Equal([]string{"message1" + sep + "message1"}))
 			}
 		})
 
 		It("should not fail on an empty message", func() {
 			obj := newObj("")
-			summary := kStatusSummarizer(obj, nil, fleetv1.Summary{})
-			Expect(summary.Message).To(BeEmpty())
+			smr := kStatusSummarizer(obj, nil, fleet.Summary{})
+			Expect(smr.Message).To(BeEmpty())
 		})
 	})
 })