Skip to content
6 changes: 6 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 124 additions & 2 deletions pkg/argocd/argocd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -592,8 +593,25 @@ 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
images = append(images, img)
// 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 && existingImg.RegistryURL == img.RegistryURL {
found = true
break
}
}

if !found {
currentImage := getImageFromSpec(app, img)
if currentImage != nil {
img.ImageTag = currentImage.ImageTag
} else {
img.ImageTag = nil
}
images = append(images, img)
}
}
}

Expand Down Expand Up @@ -727,3 +745,107 @@ func (a ApplicationType) String() string {
return "Unknown"
}
}

// 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 {
if targetImage == nil {
return nil
}

appType := getApplicationType(app)
source := getApplicationSource(app)

if source == nil {
return nil
}

switch appType {
case ApplicationTypeHelm:
if source.Helm != nil && source.Helm.Parameters != nil {
// 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 {
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
}
}
}
}
}
}
}

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
}
}
}
}
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
}
73 changes: 73 additions & 0 deletions pkg/argocd/argocd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,79 @@ 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.name",
Value: "myregistry/myapp",
},
{
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) {
Expand Down
37 changes: 34 additions & 3 deletions pkg/argocd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,40 @@ 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
// 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 {
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
}
}
}

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
Expand Down