From 2991660f10dd0f66cbf22ed5e66e81b829599664 Mon Sep 17 00:00:00 2001 From: chansuke Date: Sun, 22 Jun 2025 21:06:48 +0900 Subject: [PATCH 1/9] feat: fix force-update for zero-replica deployments by reading image tags from spec Signed-off-by: chansuke --- pkg/argocd/argocd.go | 57 +++++++++++++++++++++++++++++++- pkg/argocd/argocd_test.go | 69 +++++++++++++++++++++++++++++++++++++++ pkg/argocd/update.go | 20 ++++++++++-- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index a06d351a..7c782ec0 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -592,7 +592,14 @@ func GetImagesFromApplication(app *v1alpha1.Application) image.ContainerImageLis annotations := app.Annotations for _, img := range *parseImageList(annotations) { if img.HasForceUpdateOptionAnnotation(annotations, common.ImageUpdaterAnnotationPrefix) { - img.ImageTag = nil // the tag from the image list will be a version constraint, which isn't a valid tag + // for force-update images, try to get the current image tag from the spec + // this helps handle cases where there are 0 replicas + currentImage := getImageFromSpec(app, img) + if currentImage != nil { + img.ImageTag = currentImage.ImageTag + } else { + img.ImageTag = nil + } images = append(images, img) } } @@ -727,3 +734,51 @@ func (a ApplicationType) String() string { return "Unknown" } } + +// getImageFromSpec tries to find the current image tag from the application spec +func getImageFromSpec(app *v1alpha1.Application, targetImage *image.ContainerImage) *image.ContainerImage { + appType := getApplicationType(app) + source := getApplicationSource(app) + + if source == nil { + return nil + } + + switch appType { + case ApplicationTypeHelm: + if source.Helm != nil && source.Helm.Parameters != nil { + for _, param := range source.Helm.Parameters { + if param.Name == "image.tag" || param.Name == "image.version" { + foundImage := image.NewFromIdentifier(fmt.Sprintf("%s:%s", targetImage.ImageName, param.Value)) + if foundImage != nil && foundImage.ImageName == targetImage.ImageName { + return foundImage + } + } + if param.Name == "image" || param.Name == "image.repository" { + foundImage := image.NewFromIdentifier(param.Value) + if foundImage != nil && foundImage.ImageName == targetImage.ImageName { + return foundImage + } + } + } + } + case ApplicationTypeKustomize: + if source.Kustomize != nil && source.Kustomize.Images != nil { + for _, kustomizeImage := range source.Kustomize.Images { + imageStr := string(kustomizeImage) + if strings.Contains(imageStr, "=") { + parts := strings.SplitN(imageStr, "=", 2) + if len(parts) == 2 { + imageStr = parts[1] + } + } + foundImage := image.NewFromIdentifier(imageStr) + if foundImage != nil && foundImage.ImageName == targetImage.ImageName { + return foundImage + } + } + } + } + + return nil +} diff --git a/pkg/argocd/argocd_test.go b/pkg/argocd/argocd_test.go index 261cc9f0..58a3bb2f 100644 --- a/pkg/argocd/argocd_test.go +++ b/pkg/argocd/argocd_test.go @@ -81,6 +81,75 @@ func Test_GetImagesFromApplication(t *testing.T) { assert.Equal(t, "nginx", imageList[0].ImageName) assert.Nil(t, imageList[0].ImageTag) }) + + t.Run("Get list of images from application with force-update and zero replicas - Helm", func(t *testing.T) { + application := &v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-app", + Namespace: "argocd", + Annotations: map[string]string{ + fmt.Sprintf(registryCommon.Prefixed(common.ImageUpdaterAnnotationPrefix, registryCommon.ForceUpdateOptionAnnotationSuffix), "myapp"): "true", + common.ImageUpdaterAnnotation: "myapp=myregistry/myapp", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + Helm: &v1alpha1.ApplicationSourceHelm{ + Parameters: []v1alpha1.HelmParameter{ + { + Name: "image.tag", + Value: "1.2.3", + }, + }, + }, + }, + }, + Status: v1alpha1.ApplicationStatus{ + SourceType: v1alpha1.ApplicationSourceTypeHelm, + Summary: v1alpha1.ApplicationSummary{ + Images: []string{}, // Empty - simulating 0 replicas + }, + }, + } + imageList := GetImagesFromApplication(application) + require.Len(t, imageList, 1) + assert.Equal(t, "myregistry/myapp", imageList[0].ImageName) + assert.NotNil(t, imageList[0].ImageTag) + assert.Equal(t, "1.2.3", imageList[0].ImageTag.TagName) + }) + + t.Run("Get list of images from application with force-update and zero replicas - Kustomize", func(t *testing.T) { + application := &v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-app", + Namespace: "argocd", + Annotations: map[string]string{ + fmt.Sprintf(registryCommon.Prefixed(common.ImageUpdaterAnnotationPrefix, registryCommon.ForceUpdateOptionAnnotationSuffix), "myapp"): "true", + common.ImageUpdaterAnnotation: "myapp=myregistry/myapp", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + Kustomize: &v1alpha1.ApplicationSourceKustomize{ + Images: v1alpha1.KustomizeImages{ + "myregistry/myapp:2.3.4", + }, + }, + }, + }, + Status: v1alpha1.ApplicationStatus{ + SourceType: v1alpha1.ApplicationSourceTypeKustomize, + Summary: v1alpha1.ApplicationSummary{ + Images: []string{}, // Empty - simulating 0 replicas + }, + }, + } + imageList := GetImagesFromApplication(application) + require.Len(t, imageList, 1) + assert.Equal(t, "myregistry/myapp", imageList[0].ImageName) + assert.NotNil(t, imageList[0].ImageTag) + assert.Equal(t, "2.3.4", imageList[0].ImageTag.TagName) + }) } func Test_GetImagesAndAliasesFromApplication(t *testing.T) { diff --git a/pkg/argocd/update.go b/pkg/argocd/update.go index 42db70e4..02da54ea 100644 --- a/pkg/argocd/update.go +++ b/pkg/argocd/update.go @@ -175,9 +175,23 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat for _, applicationImage := range updateConf.UpdateApp.Images { updateableImage := applicationImages.ContainsImage(applicationImage, false) if updateableImage == nil { - log.WithContext().AddField("application", app).Debugf("Image '%s' seems not to be live in this application, skipping", applicationImage.ImageName) - result.NumSkipped += 1 - continue + // for force-update images, we should not skip them even if they're not "live" + // this handles cases like 0-replica deployments or CronJobs without active jobs + if applicationImage.HasForceUpdateOptionAnnotation(updateConf.UpdateApp.Application.Annotations, common.ImageUpdaterAnnotationPrefix) { + // find the image in our list that matches by name + for _, img := range applicationImages { + if img.ImageName == applicationImage.ImageName { + updateableImage = img + break + } + } + } + + if updateableImage == nil { + log.WithContext().AddField("application", app).Debugf("Image '%s' seems not to be live in this application, skipping", applicationImage.ImageName) + result.NumSkipped += 1 + continue + } } // In some cases, the running image has no tag set. We create a dummy From 6e84a8c69262c76b2d64bb6f6fe33c5cbb1c2c60 Mon Sep 17 00:00:00 2001 From: chansuke Date: Sat, 9 Aug 2025 19:10:54 +0900 Subject: [PATCH 2/9] fix: add regex tag pattern match Signed-off-by: chansuke --- pkg/argocd/argocd.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index 7c782ec0..984930ec 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "time" @@ -747,13 +748,24 @@ func getImageFromSpec(app *v1alpha1.Application, targetImage *image.ContainerIma switch appType { case ApplicationTypeHelm: if source.Helm != nil && source.Helm.Parameters != nil { + // Define regex patterns for tag/version parameters + tagPatterns := []*regexp.Regexp{ + regexp.MustCompile(`^(.+\.)?(tag|version|imageTag)$`), + regexp.MustCompile(`^(image|container)\.(.+\.)?(tag|version)$`), + } + for _, param := range source.Helm.Parameters { - if param.Name == "image.tag" || param.Name == "image.version" { - foundImage := image.NewFromIdentifier(fmt.Sprintf("%s:%s", targetImage.ImageName, param.Value)) - if foundImage != nil && foundImage.ImageName == targetImage.ImageName { - return foundImage + // Check if parameter matches tag/version patterns + for _, pattern := range tagPatterns { + if pattern.MatchString(param.Name) { + foundImage := image.NewFromIdentifier(fmt.Sprintf("%s:%s", targetImage.ImageName, param.Value)) + if foundImage != nil && foundImage.ImageName == targetImage.ImageName { + return foundImage + } + break } } + if param.Name == "image" || param.Name == "image.repository" { foundImage := image.NewFromIdentifier(param.Value) if foundImage != nil && foundImage.ImageName == targetImage.ImageName { From e17f316e11a6e9709a05da10941d8cfe10dccdfb Mon Sep 17 00:00:00 2001 From: chansuke Date: Sat, 9 Aug 2025 19:37:30 +0900 Subject: [PATCH 3/9] docs: add documentation for the limitation Signed-off-by: chansuke --- docs/index.md | 6 ++++++ pkg/argocd/argocd.go | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 1c9f87b2..9bcd7ed4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -96,6 +96,12 @@ Otherwise, current known limitations are: Image Updater is running in (or has access to). It is currently not possible to fetch those secrets from other clusters. +* When using Helm applications with zero-replica deployments and `force-update` + enabled, the image updater will attempt to match common Helm parameter patterns + for image tags (such as `image.tag`, `*.version`, `*.imageTag`). If your Helm + chart uses uncommon parameter names, the updater may not detect the current + image version correctly, leading to repeated update attempts. + ## Questions, help and support If you have any questions, need some help in setting things up or just want to diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index 984930ec..6c83d540 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -736,7 +736,10 @@ func (a ApplicationType) String() string { } } -// getImageFromSpec tries to find the current image tag from the application spec +// getImageFromSpec tries to find the current image tag from the application spec. +// For Helm applications, it attempts to match common parameter patterns for image tags +// using regex (e.g., image.tag, *.version, *.imageTag). However, if a Helm chart uses +// uncommon parameter names, this function may not detect them correctly. func getImageFromSpec(app *v1alpha1.Application, targetImage *image.ContainerImage) *image.ContainerImage { appType := getApplicationType(app) source := getApplicationSource(app) From 5b103eaee5f986e3d8f6d95fdf648c747ef04476 Mon Sep 17 00:00:00 2001 From: chansuke Date: Mon, 11 Aug 2025 22:36:36 +0900 Subject: [PATCH 4/9] fix: add regex tag pattern match for Helm parameters in zero-replica deployments Signed-off-by: chansuke --- pkg/argocd/argocd.go | 91 +++++++++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 22 deletions(-) diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index 6c83d540..f55648e5 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -593,15 +593,24 @@ func GetImagesFromApplication(app *v1alpha1.Application) image.ContainerImageLis annotations := app.Annotations for _, img := range *parseImageList(annotations) { if img.HasForceUpdateOptionAnnotation(annotations, common.ImageUpdaterAnnotationPrefix) { - // for force-update images, try to get the current image tag from the spec - // this helps handle cases where there are 0 replicas - currentImage := getImageFromSpec(app, img) - if currentImage != nil { - img.ImageTag = currentImage.ImageTag - } else { - img.ImageTag = nil + // Check if this image is already in the list from status + found := false + for _, existingImg := range images { + if existingImg.ImageName == img.ImageName { + found = true + break + } + } + + if !found { + currentImage := getImageFromSpec(app, img) + if currentImage != nil { + img.ImageTag = currentImage.ImageTag + } else { + img.ImageTag = nil + } + images = append(images, img) } - images = append(images, img) } } @@ -751,25 +760,63 @@ func getImageFromSpec(app *v1alpha1.Application, targetImage *image.ContainerIma switch appType { case ApplicationTypeHelm: if source.Helm != nil && source.Helm.Parameters != nil { - // Define regex patterns for tag/version parameters - tagPatterns := []*regexp.Regexp{ - regexp.MustCompile(`^(.+\.)?(tag|version|imageTag)$`), - regexp.MustCompile(`^(image|container)\.(.+\.)?(tag|version)$`), + // Try to find image name and tag parameters + var imageName, imageTag string + imageNameParam := targetImage.GetParameterHelmImageName(app.Annotations, common.ImageUpdaterAnnotationPrefix) + imageTagParam := targetImage.GetParameterHelmImageTag(app.Annotations, common.ImageUpdaterAnnotationPrefix) + + if imageNameParam == "" { + imageNameParam = registryCommon.DefaultHelmImageName + } + if imageTagParam == "" { + imageTagParam = registryCommon.DefaultHelmImageTag } - + for _, param := range source.Helm.Parameters { - // Check if parameter matches tag/version patterns - for _, pattern := range tagPatterns { - if pattern.MatchString(param.Name) { - foundImage := image.NewFromIdentifier(fmt.Sprintf("%s:%s", targetImage.ImageName, param.Value)) - if foundImage != nil && foundImage.ImageName == targetImage.ImageName { - return foundImage + if param.Name == imageNameParam { + imageName = param.Value + } + if param.Name == imageTagParam { + imageTag = param.Value + } + } + + if imageName != "" && imageTag != "" && imageName == targetImage.GetFullNameWithoutTag() { + foundImage := image.NewFromIdentifier(fmt.Sprintf("%s:%s", imageName, imageTag)) + if foundImage != nil { + return foundImage + } + } + + if imageTag == "" { + tagPatterns := []*regexp.Regexp{ + regexp.MustCompile(`^(.+\.)?(tag|version|imageTag)$`), + regexp.MustCompile(`^(image|container)\.(.+\.)?(tag|version)$`), + } + + for _, param := range source.Helm.Parameters { + for _, pattern := range tagPatterns { + if pattern.MatchString(param.Name) && param.Value != "" { + prefix := strings.TrimSuffix(param.Name, ".tag") + prefix = strings.TrimSuffix(prefix, ".version") + prefix = strings.TrimSuffix(prefix, ".imageTag") + + for _, p := range source.Helm.Parameters { + if (p.Name == prefix || p.Name == prefix+".name" || p.Name == prefix+".repository") && + p.Value == targetImage.GetFullNameWithoutTag() { + foundImage := image.NewFromIdentifier(fmt.Sprintf("%s:%s", targetImage.GetFullNameWithoutTag(), param.Value)) + if foundImage != nil { + return foundImage + } + } + } } - break } } - - if param.Name == "image" || param.Name == "image.repository" { + } + + for _, param := range source.Helm.Parameters { + if param.Name == "image" || param.Name == "image.repository" || param.Name == registryCommon.DefaultHelmImageName { foundImage := image.NewFromIdentifier(param.Value) if foundImage != nil && foundImage.ImageName == targetImage.ImageName { return foundImage From 1b8c2a04980c221083b3f268c53b6fe0a0fa7bd5 Mon Sep 17 00:00:00 2001 From: chansuke Date: Mon, 11 Aug 2025 22:45:45 +0900 Subject: [PATCH 5/9] fix: add nil check Signed-off-by: chansuke --- pkg/argocd/argocd.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index f55648e5..7c5d0d8e 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -750,6 +750,10 @@ func (a ApplicationType) String() string { // using regex (e.g., image.tag, *.version, *.imageTag). However, if a Helm chart uses // uncommon parameter names, this function may not detect them correctly. func getImageFromSpec(app *v1alpha1.Application, targetImage *image.ContainerImage) *image.ContainerImage { + if targetImage == nil { + return nil + } + appType := getApplicationType(app) source := getApplicationSource(app) From 86bd50d02f4d0918f429afc34cce4d012ed8c32e Mon Sep 17 00:00:00 2001 From: chansuke Date: Mon, 11 Aug 2025 22:46:07 +0900 Subject: [PATCH 6/9] fix: add test Signed-off-by: chansuke --- pkg/argocd/argocd_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/argocd/argocd_test.go b/pkg/argocd/argocd_test.go index 58a3bb2f..4af54aad 100644 --- a/pkg/argocd/argocd_test.go +++ b/pkg/argocd/argocd_test.go @@ -96,6 +96,10 @@ func Test_GetImagesFromApplication(t *testing.T) { Source: &v1alpha1.ApplicationSource{ Helm: &v1alpha1.ApplicationSourceHelm{ Parameters: []v1alpha1.HelmParameter{ + { + Name: "image.name", + Value: "myregistry/myapp", + }, { Name: "image.tag", Value: "1.2.3", From 408525815dbfb02b381e31ae21f7e4057219cfe3 Mon Sep 17 00:00:00 2001 From: chansuke Date: Thu, 14 Aug 2025 01:59:05 +0900 Subject: [PATCH 7/9] chore: remove unnecessary space Signed-off-by: chansuke --- pkg/argocd/argocd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index 7c5d0d8e..d79a474b 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -753,7 +753,7 @@ func getImageFromSpec(app *v1alpha1.Application, targetImage *image.ContainerIma if targetImage == nil { return nil } - + appType := getApplicationType(app) source := getApplicationSource(app) From 410c282ef8fdbdf0b1e9b8515cad17567660f2a3 Mon Sep 17 00:00:00 2001 From: chansuke Date: Thu, 14 Aug 2025 02:34:34 +0900 Subject: [PATCH 8/9] fix: improve Helm parameter detection for zero-replica deployments Signed-off-by: chansuke --- pkg/argocd/argocd.go | 3 ++- pkg/argocd/update.go | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index d79a474b..e18a25fb 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -594,9 +594,10 @@ func GetImagesFromApplication(app *v1alpha1.Application) image.ContainerImageLis for _, img := range *parseImageList(annotations) { if img.HasForceUpdateOptionAnnotation(annotations, common.ImageUpdaterAnnotationPrefix) { // Check if this image is already in the list from status + // We only consider it a duplicate if both the registry and image name match found := false for _, existingImg := range images { - if existingImg.ImageName == img.ImageName { + if existingImg.ImageName == img.ImageName && existingImg.RegistryURL == img.RegistryURL { found = true break } diff --git a/pkg/argocd/update.go b/pkg/argocd/update.go index 02da54ea..aafcb609 100644 --- a/pkg/argocd/update.go +++ b/pkg/argocd/update.go @@ -179,8 +179,25 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat // this handles cases like 0-replica deployments or CronJobs without active jobs if applicationImage.HasForceUpdateOptionAnnotation(updateConf.UpdateApp.Application.Annotations, common.ImageUpdaterAnnotationPrefix) { // find the image in our list that matches by name + // Compare without registry prefix to handle different registries + appImgNameWithoutRegistry := applicationImage.ImageName + if strings.Contains(appImgNameWithoutRegistry, "/") { + parts := strings.Split(appImgNameWithoutRegistry, "/") + if len(parts) >= 2 && strings.Contains(parts[0], ".") { + appImgNameWithoutRegistry = strings.Join(parts[1:], "/") + } + } + for _, img := range applicationImages { - if img.ImageName == applicationImage.ImageName { + imgNameWithoutRegistry := img.ImageName + if strings.Contains(imgNameWithoutRegistry, "/") { + parts := strings.Split(imgNameWithoutRegistry, "/") + if len(parts) >= 2 && strings.Contains(parts[0], ".") { + imgNameWithoutRegistry = strings.Join(parts[1:], "/") + } + } + + if img.ImageName == applicationImage.ImageName || imgNameWithoutRegistry == appImgNameWithoutRegistry { updateableImage = img break } From f2bf543287974da45ce911c2a75416ce65aedebf Mon Sep 17 00:00:00 2001 From: chansuke Date: Thu, 14 Aug 2025 02:38:38 +0900 Subject: [PATCH 9/9] fix: lint Signed-off-by: chansuke --- pkg/argocd/update.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/argocd/update.go b/pkg/argocd/update.go index aafcb609..a83a0d31 100644 --- a/pkg/argocd/update.go +++ b/pkg/argocd/update.go @@ -187,7 +187,7 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat appImgNameWithoutRegistry = strings.Join(parts[1:], "/") } } - + for _, img := range applicationImages { imgNameWithoutRegistry := img.ImageName if strings.Contains(imgNameWithoutRegistry, "/") { @@ -196,7 +196,7 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat imgNameWithoutRegistry = strings.Join(parts[1:], "/") } } - + if img.ImageName == applicationImage.ImageName || imgNameWithoutRegistry == appImgNameWithoutRegistry { updateableImage = img break