Repository: rancher/fleet

Issue:
HelmOps main controller does not update Status.Version
The call to `Patch` in the main `helmops` controller does not really update the Version when we are not running polling, because the Version found in the original object and the Version found in the new object are identical.

When calcultating the patch Version is ignored because it is the same in both objects.

Hints from issue comments:
@0xavi0: # QA Template

1. Apply a `HelmOp` with NO polling, using a fixed helm chart version. 
2. Check that `.Status.Version` has the version value in the `HelmOp`

You can also check with a non-fixed version (something like 1.0.x), the important bit is to not use polling. @mmartin24: Verified in Rancher `v2.12-122ba407047ed762689d3778c1bdafb4fbb91004-head` with Fleet `fleet:107.0.0+up0.13.0-beta.4`

---

Used for test this file which expects to deploy latest grafana chart (`2.9.10` at the time of this test):

```yaml
apiVersion: fleet.cattle.io/v1alpha1
kind: HelmOp
metadata:
  name: grafana-test
  namespace: fleet-local
spec:
  correctDrift:
    enabled: false
  helm:
    chart: grafana
    releaseName: grafana
    repo: https://grafana.github.io/helm-charts
    version: 9.2.x
  pollingInterval: 0s
  targets:
    - clusterSelector:
        matchExpressions:
          - key: provider.cattle.io
            operator: NotIn
            values:
              - harvester
```

Status version shown: 
<details close>
  <summary>grafana_result.yaml</summary>
  
  ```yaml
  apiVersion: fleet.cattle.io/v1alpha1
kind: HelmOp
metadata:
  creationTimestamp: '2025-07-15T12:29:04Z'
  finalizers:
    - fleet.cattle.io/helmop-finalizer
  generation: 2
  managedFields:
    - apiVersion: fleet.cattle.io/v1alpha1
      fieldsType: FieldsV1
      fieldsV1:
        f:metadata:
          f:finalizers:
            .: {}
            v:"fleet.cattle.io/helmop-finalizer": {}
      manager: fleetcontroller
      operation: Update
      time: '2025-07-15T12:29:04Z'
    - apiVersion: fleet.cattle.io/v1alpha1
      fieldsType: FieldsV1
      fieldsV1:
        f:spec:
          .: {}
          f:correctDrift: {}
          f:helm:
            .: {}
            f:chart: {}
            f:releaseName: {}
            f:repo: {}
            f:version: {}
          f:pollingInterval: {}
          f:targets: {}
      manager: rancher
      operation: Update
      time: '2025-07-15T12:29:04Z'
    - apiVersion: fleet.cattle.io/v1alpha1
      fieldsType: FieldsV1
      fieldsV1:
        f:status:
          .: {}
          f:conditions: {}
          f:desiredReadyClusters: {}
          f:display:
            .: {}
            f:readyBundleDeployments: {}
          f:perClusterResourceCounts:
            .: {}
            f:fleet-local/local:
              .: {}
              f:desiredReady: {}
              f:missing: {}
              f:modified: {}
              f:notReady: {}
              f:orphaned: {}
              f:ready: {}
              f:unknown: {}
              f:waitApplied: {}
          f:readyClusters: {}
          f:resourceCounts:
            .: {}
            f:desiredReady: {}
            f:notReady: {}
            f:ready: {}
          f:resources: {}
          f:summary:
            .: {}
            f:desiredReady: {}
            f:ready: {}
          f:version: {}
      manager: fleetcontroller
      operation: Update
      subresource: status
      time: '2025-07-15T12:29:23Z'
  name: grafana-test
  namespace: fleet-local
  resourceVersion: '31373'
  uid: 71069c75-f723-496e-8dae-270347b4c695
spec:
  correctDrift: {}
  helm:
    chart: grafana
    releaseName: grafana
    repo: https://grafana.github.io/helm-charts
    version: 9.2.x
  pollingInterval: 0s
  targets:
    - clusterSelector:
        matchExpressions:
          - key: provider.cattle.io
            operator: NotIn
            values:
              - harvester
status:
  conditions:
    - lastUpdateTime: '2025-07-15T12:29:23Z'
      status: 'True'
      type: Ready
    - lastUpdateTime: '2025-07-15T12:29:04Z'
      status: 'True'
      type: Accepted
  desiredReadyClusters: 1
  display:
    readyBundleDeployments: 1/1
  perClusterResourceCounts:
    fleet-local/local:
      desiredReady: 9
      missing: 0
      modified: 0
      notReady: 0
      orphaned: 0
      ready: 9
      unknown: 0
      waitApplied: 0
  readyClusters: 1
  resourceCounts:
    desiredReady: 9
    notReady: 0
    ready: 9
  resources:
    - apiVersion: apps/v1
      id: default/grafana
      kind: Deployment
      name: grafana
      namespace: default
      perClusterState:
        ready:
          - fleet-local/local
      state: Ready
      type: apps.deployment
    - apiVersion: v1
      id: default/grafana
      kind: ConfigMap
      name: grafana
      namespace: default
      perClusterState:
        ready:
          - fleet-local/local
      state: Ready
      type: configmap
    - apiVersion: rbac.authorization.k8s.io/v1
      id: grafana-clusterrole
      kind: ClusterRole
      name: grafana-clusterrole
      perClusterState:
        ready:
          - fleet-local/local
      state: Ready
      type: rbac.authorization.k8s.io.clusterrole
    - apiVersion: rbac.authorization.k8s.io/v1
      id: grafana-clusterrolebinding
      kind: ClusterRoleBinding
      name: grafana-clusterrolebinding
      perClusterState:
        ready:
          - fleet-local/local
      state: Ready
      type: rbac.authorization.k8s.io.clusterrolebinding
    - apiVersion: rbac.authorization.k8s.io/v1
      id: default/grafana
      kind: Role
      name: grafana
      namespace: default
      perClusterState:
        ready:
          - fleet-local/local
      state: Ready
      type: rbac.authorization.k8s.io.role
    - apiVersion: rbac.authorization.k8s.io/v1
      id: default/grafana
      kind: RoleBinding
      name: grafana
      namespace: default
      perClusterState:
        ready:
          - fleet-local/local
      state: Ready
      type: rbac.authorization.k8s.io.rolebinding
    - apiVersion: v1
      id: default/grafana
      kind: Secret
      name: grafana
      namespace: default
      perClusterState:
        ready:
          - fleet-local/local
      state: Ready
      type: secret
    - apiVersion: v1
      id: default/grafana
      kind: Service
      name: grafana
      namespace: default
      perClusterState:
        ready:
          - fleet-local/local
      state: Ready
      type: service
    - apiVersion: v1
      id: default/grafana
      kind: ServiceAccount
      name: grafana
      namespace: default
      perClusterState:
        ready:
          - fleet-local/local
      state: Ready
      type: serviceaccount
  summary:
    desiredReady: 1
    ready: 1
  version: 9.2.10
  ```
</details>

<img width="968" height="836" alt="Image" src="https://github.com/user-attachments/assets/ffc4b9ed-5676-4e84-b167-f46690faf7a7" />

However the UI seems to display the spec.version rather than the latest version deployed: 

<img width="1171" height="496" alt="Image" src="https://github.com/user-attachments/assets/ae6ecc09-fdff-47d9-b7bb-271fbbdaf9c4" />

<img width="1060" height="490" alt="Image" src="https://github.com/user-attachments/assets/7c5ef50a-7fd0-4b9e-a6f5-e345948d923e" />

I will spin an issue for that


@mmartin24: Issue mentioned above opened here: https://github.com/rancher/dashboard/issues/14808 

Solution patch:
diff --git a/internal/cmd/controller/helmops/reconciler/helmop_controller.go b/internal/cmd/controller/helmops/reconciler/helmop_controller.go
index 98f821dd..66e8a48c 100644
--- a/internal/cmd/controller/helmops/reconciler/helmop_controller.go
+++ b/internal/cmd/controller/helmops/reconciler/helmop_controller.go
@@ -438,11 +438,13 @@ func usesPolling(helmop fleet.HelmOp) bool {
 // updateStatus updates the status for the HelmOp resource. It retries on
 // conflict. If the status was updated successfully, it also collects (as in
 // updates) metrics for the HelmOp resource.
-func updateStatus(ctx context.Context, c client.Client, req types.NamespacedName, orig *fleet.HelmOp, orgErr error) error {
-	if orig == nil {
+func updateStatus(ctx context.Context, c client.Client, req types.NamespacedName, helmop *fleet.HelmOp, orgErr error) error {
+	if helmop == nil {
 		return fmt.Errorf("the HelmOp provided for a status update is nil; this should not happen")
 	}
 
+	objToPatchFrom := helmop.DeepCopy()
+
 	return retry.RetryOnConflict(retry.DefaultRetry, func() error {
 		t := &fleet.HelmOp{}
 		if err := c.Get(ctx, req, t); err != nil {
@@ -450,7 +452,17 @@ func updateStatus(ctx context.Context, c client.Client, req types.NamespacedName
 		}
 
 		// selectively update the status fields this reconciler is responsible for
-		t.Status.Version = orig.Status.Version
+		if t.Status.Version != objToPatchFrom.Status.Version && objToPatchFrom.Status.Version != "" {
+			t.Status.Version = objToPatchFrom.Status.Version
+			// (#3883)
+			// If orig.Status.Version is, for example, equal to 1.0.0, when
+			// assigning the Version to t.Status.Version both will be 1.0.0.
+			// When calculating the Patch data, Status.Version will be ignored because
+			// both objects have the same value.
+			// The following cleanup prevents that so Status.Version is taken into
+			// account when calculating the patch data.
+			objToPatchFrom.Status.Version = ""
+		}
 
 		// only keep the Ready condition from live status, it's calculated by the status reconciler
 		conds := []genericcondition.GenericCondition{}
@@ -460,7 +472,7 @@ func updateStatus(ctx context.Context, c client.Client, req types.NamespacedName
 				break
 			}
 		}
-		for _, c := range orig.Status.Conditions {
+		for _, c := range objToPatchFrom.Status.Conditions {
 			if c.Type == "Ready" {
 				continue
 			}
@@ -470,7 +482,7 @@ func updateStatus(ctx context.Context, c client.Client, req types.NamespacedName
 
 		setAcceptedConditionHelm(&t.Status, orgErr)
 
-		statusPatch := client.MergeFrom(orig)
+		statusPatch := client.MergeFrom(objToPatchFrom)
 		if patchData, err := statusPatch.Data(t); err == nil && string(patchData) == "{}" {
 			metrics.HelmCollector.Collect(ctx, t)
 			// skip update if patch is empty



Test patch:
diff --git a/e2e/single-cluster/helmop_test.go b/e2e/single-cluster/helmop_test.go
index 5f7b1301..f87b7027 100644
--- a/e2e/single-cluster/helmop_test.go
+++ b/e2e/single-cluster/helmop_test.go
@@ -95,6 +95,12 @@ var _ = Describe("HelmOp resource tests with polling", Label("infra-setup", "hel
 					outDeployments, _ := k.Namespace(namespace).Get("deployments")
 					return strings.Contains(outDeployments, "sleeper")
 				}).Should(BeTrue())
+
+				By("setting the expected version in the helmop Status")
+				Eventually(func() string {
+					out, _ := k.Get("helmop", name, "-o=jsonpath={.status.version}")
+					return out
+				}).Should(Equal(chartVersion))
 			})
 		})
 	})
@@ -174,6 +180,11 @@ var _ = Describe("HelmOp resource tests with polling", Label("infra-setup", "hel
 				outDeployments, _ := k.Namespace(namespace).Get("deployments")
 				return strings.Contains(outDeployments, "sleeper2")
 			}).Should(BeTrue())
+			By("setting the expected version in the helmop Status")
+			Eventually(func() string {
+				out, _ := k.Get("helmop", name, "-o=jsonpath={.status.version}")
+				return out
+			}).Should(Equal("0.2.0"))
 		})
 	})
 })