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 a06d351a..e18a25fb 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "time" @@ -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) + } } } @@ -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 +} diff --git a/pkg/argocd/argocd_test.go b/pkg/argocd/argocd_test.go index 261cc9f0..4af54aad 100644 --- a/pkg/argocd/argocd_test.go +++ b/pkg/argocd/argocd_test.go @@ -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) { diff --git a/pkg/argocd/update.go b/pkg/argocd/update.go index 2cb7836f..bd0d3cfd 100644 --- a/pkg/argocd/update.go +++ b/pkg/argocd/update.go @@ -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