Skip to content

feat: fix force-update for zero-replica deployments by reading image tags from spec #1172

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
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