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 6 commits into
base: master
Choose a base branch
from
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
125 changes: 123 additions & 2 deletions pkg/argocd/argocd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -592,8 +593,24 @@
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
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)
}
}
}

Expand Down Expand Up @@ -727,3 +744,107 @@
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
}

Check failure on line 756 in pkg/argocd/argocd.go

View workflow job for this annotation

GitHub Actions / Ensure code is correctly linted

File is not properly formatted (gofmt)
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
}
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we're creating foundImage with targetImage.ImageName, and then compare the 2ImageName, which will always be true.

How can we make sure this matched tag param is for the target image? Think of multiple images and params in a helm values file, with params like foo.image.name, foo.image.tag, bar.image.name, bar.image.tag.

}
}
}

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
20 changes: 17 additions & 3 deletions pkg/argocd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading