Repository: argoproj-labs/argocd-image-updater

Issue:
Cannot use digest update strategy with forceUpdate: true without version constraint
**Describe the bug**

The argocd-image-updater does not update Kubernetes Jobs when `forceUpdate: true` is set in the ImageUpdater CRD. When `forceUpdate: true` is enabled with `updateStrategy: "digest"`, the image updater throws an error indicating it cannot use the digest update strategy without a version constraint.

**To Reproduce**

1. Create an ImageUpdater CRD with the following configuration:
   ```yaml
   apiVersion: argocd-image-updater.argoproj.io/v1alpha1
   kind: ImageUpdater
   metadata:
     name: example-image-updater
   spec:
     namespace: argocd
     commonUpdateSettings:
       forceUpdate: true
       updateStrategy: "digest"
     writeBackConfig:
       method: "argocd"
     applicationRefs:
       - namePattern: "example-app"
         images:
           - alias: frontend-widget
             imageName: "example-registry:pr-25969"
             manifestTargets:
               helm:
                 name: "global.image.name"
                 tag: "global.image.tag"
   ```

2. Apply the ImageUpdater CRD to the cluster
3. Observe the error in the argocd-image-updater logs

**Expected behavior**

The image updater should successfully update Kubernetes Jobs when `forceUpdate: true` is set, similar to how it worked with the previous annotated argocd image updater version. The digest update strategy should work with `forceUpdate: true` without requiring a version constraint.

**Additional context**

- This appears to be a regression from the previous annotated argocd image updater version, where this configuration worked as expected
- When `forceUpdate: false` is set (or omitted), the image updater works correctly
- The error occurs specifically when trying to update images that target Kubernetes Jobs

**Version**

`1.0.0`

**Logs**

```
msg="Could not get tags from registry: cannot use update strategy 'digest' for image 'frontend-widget=example-registry:pr-25969' without a version constraint"
```



Discussion:
@chengfang: Thanks for reporting this regression. Working on a fix.

Solution patch:
diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go
index 4e67c10..0948463 100644
--- a/pkg/argocd/argocd.go
+++ b/pkg/argocd/argocd.go
@@ -807,8 +807,14 @@ func GetImagesFromApplication(applicationImages *ApplicationImages) image.Contai
 
 	for _, img := range applicationImages.Images {
 		if img.ForceUpdate {
-			img.ImageTag = nil // the tag from the image list will be a version constraint, which isn't a valid tag
-			images = append(images, img.ContainerImage)
+			// Create a copy of the container image with nil tag to add to the live images list.
+			// The tag from the image list will be a version constraint, which isn't a valid tag.
+			// We preserve the original img.ContainerImage so the constraint is available later.
+			imgCopy := img.ContainerImage.WithTag(nil)
+			// Avoid duplicates if an entry already exists for this image (ignore tag for match).
+			if images.ContainsImage(img.ContainerImage, false) == nil {
+				images = append(images, imgCopy)
+			}
 		}
 	}
 



Test patch:
diff --git a/pkg/argocd/argocd_test.go b/pkg/argocd/argocd_test.go
index a298499..9010d8d 100644
--- a/pkg/argocd/argocd_test.go
+++ b/pkg/argocd/argocd_test.go
@@ -93,6 +93,85 @@ func Test_GetImagesFromApplication(t *testing.T) {
 		assert.Equal(t, "nginx", imageList[0].ImageName)
 		assert.Nil(t, imageList[0].ImageTag)
 	})
+
+	t.Run("Force-update with digest strategy preserves constraint (issue #1344)", func(t *testing.T) {
+		application := &v1alpha1.Application{
+			ObjectMeta: v1.ObjectMeta{
+				Name:      "test-app",
+				Namespace: "argocd",
+			},
+			Spec: v1alpha1.ApplicationSpec{},
+			Status: v1alpha1.ApplicationStatus{
+				Summary: v1alpha1.ApplicationSummary{},
+			},
+		}
+		// Create image with tag (which will be used as constraint for digest strategy)
+		// This mimics the scenario: forceUpdate=true + updateStrategy=digest + tag as version constraint
+		imgToUpdate := image.NewFromIdentifier("nginx:1.14")
+		img := NewImage(imgToUpdate)
+		img.ForceUpdate = true
+		img.UpdateStrategy = image.StrategyDigest
+
+		applicationImages := &ApplicationImages{
+			Application: *application,
+			Images:      ImageList{img},
+		}
+
+		// Get the live images list
+		imageList := GetImagesFromApplication(applicationImages)
+		require.Len(t, imageList, 1)
+		assert.Equal(t, "nginx", imageList[0].ImageName)
+		// The returned image should have nil tag (it's for matching with live images)
+		assert.Nil(t, imageList[0].ImageTag)
+		// Ensure a copy was created (no in-place mutation of the original).
+		require.NotSame(t, img.ContainerImage, imageList[0])
+
+		// CRITICAL: The original image's tag should be preserved for use as version constraint
+		// This is the fix for issue #1344 - without the fix, img.ImageTag would be nil here
+		require.NotNil(t, img.ImageTag, "Original image tag should be preserved for constraint")
+		assert.Equal(t, "1.14", img.ImageTag.TagName, "Constraint tag should be available")
+
+		// Verify the update strategy is still set correctly
+		assert.Equal(t, image.StrategyDigest, img.UpdateStrategy, "Update strategy should remain digest")
+
+		// Verify that the constraint would work with digest strategy validation
+		// Digest strategy requires a non-empty constraint (vc.Constraint != "")
+		assert.NotEmpty(t, img.ImageTag.TagName, "Constraint must be non-empty for digest strategy")
+	})
+
+	t.Run("Force-update avoids duplicates when image already in status", func(t *testing.T) {
+		application := &v1alpha1.Application{
+			ObjectMeta: v1.ObjectMeta{
+				Name:      "test-app",
+				Namespace: "argocd",
+			},
+			Spec: v1alpha1.ApplicationSpec{},
+			Status: v1alpha1.ApplicationStatus{
+				Summary: v1alpha1.ApplicationSummary{
+					// Image already exists in the application status
+					Images: []string{
+						"nginx:1.14",
+					},
+				},
+			},
+		}
+		// Create image with forceUpdate that has the same name/registry as the status image
+		imgToUpdate := image.NewFromIdentifier("nginx:1.14")
+		img := NewImage(imgToUpdate)
+		img.ForceUpdate = true
+
+		applicationImages := &ApplicationImages{
+			Application: *application,
+			Images:      ImageList{img},
+		}
+
+		// Get the live images list
+		imageList := GetImagesFromApplication(applicationImages)
+
+		// Should only have 1 image (from status), not 2 (duplicate prevented)
+		require.Len(t, imageList, 1, "Should not create duplicate entry for image already in status")
+		assert.Equal(t, "nginx", imageList[0].ImageName)
+	})
 }
 
 func Test_GetImagesAndAliasesFromApplication(t *testing.T) {
diff --git a/pkg/argocd/update_test.go b/pkg/argocd/update_test.go
index bd38be3..2336341 100644
--- a/pkg/argocd/update_test.go
+++ b/pkg/argocd/update_test.go
@@ -1236,6 +1236,114 @@ func Test_UpdateApplication(t *testing.T) {
 		assert.Equal(t, 0, res.NumImagesUpdated)
 	})
 
+	t.Run("Test Kubernetes Job with forceUpdate and digest strategy (issue #1344)", func(t *testing.T) {
+		// This test reproduces the scenario from issue #1344:
+		// - Application uses a Kubernetes Job (not in app.Status.Summary.Images)
+		// - forceUpdate: true is set
+		// - updateStrategy: "digest" is used
+		// - A version constraint (tag) is provided
+
+		mockClientFn := func(endpoint *registry.RegistryEndpoint, username, password string) (registry.RegistryClient, error) {
+			regMock := regmock.RegistryClient{}
+			regMock.On("NewRepository", mock.MatchedBy(func(s string) bool {
+				return s == "org/job-image"
+			})).Return(nil)
+			// Return the tag that matches our version constraint
+			regMock.On("Tags", mock.Anything).Return([]string{"latest"}, nil)
+
+			// For digest strategy, we need to mock ManifestForTag and TagMetadata
+			meta1 := &schema1.SignedManifest{} //nolint:staticcheck
+			meta1.Name = "org/job-image"
+			meta1.Tag = "latest"
+			regMock.On("ManifestForTag", mock.Anything, "latest").Return(meta1, nil)
+			// Create a digest as [32]byte array
+			var digest [32]byte
+			copy(digest[:], []byte("abcdef1234567890"))
+			regMock.On("TagMetadata", mock.Anything, mock.Anything, mock.Anything).Return(&tag.TagInfo{
+				CreatedAt: time.Unix(1234567890, 0),
+				Digest:    digest,
+			}, nil)
+
+			return &regMock, nil
+		}
+
+		argoClient := argomock.ArgoCD{}
+		argoClient.On("UpdateSpec", mock.Anything, mock.Anything).Return(nil, nil)
+
+		kubeClient := kube.ImageUpdaterKubernetesClient{
+			KubeClient: &registryKube.KubernetesClient{
+				Clientset: fake.NewFakeKubeClient(),
+			},
+		}
+
+		// Image configuration with forceUpdate and digest strategy
+		// The tag "latest" serves as the version constraint for digest strategy
+		containerImg := image.NewFromIdentifier("job-image=gcr.io/org/job-image:latest")
+		iuImg := NewImage(containerImg)
+		iuImg.KustomizeImageName = "org/job-image"
+		iuImg.ForceUpdate = true
+		iuImg.UpdateStrategy = image.StrategyDigest
+
+		imageList := ImageList{iuImg}
+
+		appImages := &ApplicationImages{
+			Application: v1alpha1.Application{
+				ObjectMeta: v1.ObjectMeta{
+					Name:      "job-app",
+					Namespace: "default",
+				},
+				Spec: v1alpha1.ApplicationSpec{
+					Source: &v1alpha1.ApplicationSource{
+						Kustomize: &v1alpha1.ApplicationSourceKustomize{
+							Images: v1alpha1.KustomizeImages{},
+						},
+					},
+				},
+				Status: v1alpha1.ApplicationStatus{
+					SourceType: v1alpha1.ApplicationSourceTypeKustomize,
+					Summary: v1alpha1.ApplicationSummary{
+						// Empty images list - simulating a Kubernetes Job that doesn't
+						// appear in the application status
+						Images: []string{},
+					},
+				},
+			},
+			Images: imageList,
+			WriteBackConfig: &WriteBackConfig{
+				Method: WriteBackApplication,
+			},
+		}
+
+		// Before the fix for issue #1344, this would fail with:
+		// "cannot use update strategy 'digest' for image... without a version constraint"
+		// because the constraint was lost when setting ImageTag to nil
+		res := UpdateApplication(context.Background(), &UpdateConfiguration{
+			NewRegFN:   mockClientFn,
+			ArgoClient: &argoClient,
+			KubeClient: &kubeClient,
+			UpdateApp:  appImages,
+			DryRun:     false,
+		}, NewSyncIterationState())
+
+		// Verify the update succeeded
+		assert.Equal(t, 0, res.NumErrors, "Should not have errors with forceUpdate + digest strategy")
+		assert.Equal(t, 0, res.NumSkipped)
+		assert.Equal(t, 1, res.NumApplicationsProcessed)
+		assert.Equal(t, 1, res.NumImagesConsidered)
+		assert.Equal(t, 1, res.NumImagesUpdated, "Image should be updated with digest")
+
+		// Verify the kustomize image was updated with the digest
+		require.Len(t, appImages.Application.Spec.Source.Kustomize.Images, 1)
+		updatedImage := string(appImages.Application.Spec.Source.Kustomize.Images[0])
+		assert.Contains(t, updatedImage, "gcr.io/org/job-image")
+		assert.Contains(t, updatedImage, "latest")
+		assert.Contains(t, updatedImage, "sha256:", "Image should include digest")
+
+		// The constraint on the original image must still be present.
+		require.NotNil(t, iuImg.ContainerImage.ImageTag)
+		assert.Equal(t, "latest", iuImg.ContainerImage.ImageTag.TagName)
+	})
+
 }
 
 func Test_MarshalParamsOverride(t *testing.T) {