Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -604,8 +605,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 @@ -739,3 +757,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 @@ -177,9 +177,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
Loading