diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 1988a508..893dd378 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -5,6 +5,7 @@ bacd CVE credref DEBU +bitnami dockerhub eec fbd @@ -19,4 +20,5 @@ someimage somerepo ssw wildcards +wordpress yyyy diff --git a/docs/configuration/images.md b/docs/configuration/images.md index dc492981..962d40b9 100644 --- a/docs/configuration/images.md +++ b/docs/configuration/images.md @@ -353,6 +353,88 @@ If the `.helm.image-spec` annotation is set, the two other annotations `.helm.image-name` and `.helm.image-tag` will be ignored. +## Targeting specific sources in a multi-source application + +!!! warning "Beta Feature" + Multi-source applications are currently considered a beta feature within ArgoCD, therefore ArgoCD Image Updater's support for it should be considered beta as well. For more information on this feature, please consult the [ArgoCD documentation](https://argo-cd.readthedocs.io/en/stable/user-guide/multiple_sources/). + +Typical applications only have a single source associated with them, however with ArgoCD 2.6+ multiple sources can be specified for a single application. This can occasionally serve as an alternative to the Application of Applications pattern. This may also require the ability to limit the scope of an image update to a single source. Consider the following example of a multi-source application definition: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + annotations: + argocd-image-updater.argoproj.io/image-list: wordpress=bitnami/wordpress:6,nginx=bitnami/nginx:1.25 + argocd-image-updater.argoproj.io/wordpress.helm.image-name: image.repository + argocd-image-updater.argoproj.io/wordpress.helm.image-tag: image.tag + argocd-image-updater.argoproj.io/nginx.source-index: 1 + argocd-image-updater.argoproj.io/nginx.helm.image-name: image.repository + argocd-image-updater.argoproj.io/nginx.helm.source-index: image.tag + name: multi-source-app + namespace: argocd +spec: + destination: + namespace: guestbook + server: https://kubernetes.default.svc + project: default + sources: + - chart: wordpress + repoURL: https://charts.bitnami.com/bitnami + targetRevision: ^18.0.0 + - chart: nginx + repoURL: https://charts.bitnami.com/bitnami + targetRevision: ^15.0.0 +``` + +*Scenario:* Both of these images are controlled by the same value file variables +(`image.repository` and `image.tag`) within their respective Helm charts. In the case +where both the wordpress and nginx images were to be included by child applications, +referenced by a single parent application (Application of Applications pattern), there +would be an opportunity to control each chart values individually with separate values +to be applied to each chart. This would be solved similarly in the scenario where one +or more of the images are pulled in with sub-charts. + +*Solution:* In order to properly scope the overrides for each individual helm chart in +a multi-source application, the `source-index` annotation must be provided for each +image as shown below. + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + annotations: + argocd-image-updater.argoproj.io/image-list: wordpress=bitnami/wordpress:6,nginx=bitnami/nginx:1.25 + argocd-image-updater.argoproj.io/wordpress.source-index: 0 + argocd-image-updater.argoproj.io/wordpress.helm.image-name: image.repository + argocd-image-updater.argoproj.io/wordpress.helm.image-tag: image.tag + argocd-image-updater.argoproj.io/nginx.source-index: 1 + argocd-image-updater.argoproj.io/nginx.helm.image-name: image.repository + argocd-image-updater.argoproj.io/nginx.helm.image-tag: image.tag + name: multi-source-app + namespace: argocd +spec: + destination: + namespace: guestbook + server: https://kubernetes.default.svc + project: default + sources: + - chart: wordpress + repoURL: https://charts.bitnami.com/bitnami + targetRevision: ^18.0.0 + - chart: nginx + repoURL: https://charts.bitnami.com/bitnami + targetRevision: ^15.0.0 +``` + +The general syntax for the `source-index` annotation is: +```yaml +argocd-image-updater.argoproj.io/.source-index: +``` +!!!note + The value of the `source-index` annotation is 0-based, meaning that the first source would be `0`, the second + would be `1`, and so on. + ## Examples ### Following an image's patch branch diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index d7c9394e..f7a494c8 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -184,12 +184,6 @@ func FilterApplicationsForUpdate(apps []v1alpha1.Application, patterns []string, continue } - // Check for valid application type - if !IsValidApplicationType(&app) { - logCtx.Warnf("skipping app '%s' of type '%s' because it's not of supported source type", app.GetName(), app.Status.SourceType) - continue - } - // Check if application name matches requested patterns if !nameMatchesPattern(app.GetName(), patterns) { logCtx.Debugf("Skipping app '%s' because it does not match requested patterns", app.GetName()) @@ -201,13 +195,20 @@ func FilterApplicationsForUpdate(apps []v1alpha1.Application, patterns []string, logCtx.Debugf("Skipping app '%s' because it does not carry requested label", app.GetName()) continue } + for sourceIndex := range getApplicationTypes(&app) { + // Check for valid application type + if !IsValidApplicationTypeForSource(&app, sourceIndex) { + logCtx.Infof("skipping application '%s' source index %d of type '%s' because it's not a supported source type", app.GetName(), sourceIndex, GetSourceTypes(app.Status)[sourceIndex]) + continue + } - logCtx.Tracef("processing app '%s' of type '%v'", app.GetName(), app.Status.SourceType) - imageList := parseImageList(annotations) - appImages := ApplicationImages{} - appImages.Application = app - appImages.Images = *imageList - appsForUpdate[app.GetName()] = appImages + logCtx.Tracef("processing application '%s' source index %d of type '%s'", app.GetName(), sourceIndex, GetSourceTypes(app.Status)[sourceIndex]) + imageList := parseImageList(annotations) + appImages := ApplicationImages{} + appImages.Application = app + appImages.Images = *imageList + appsForUpdate[app.GetName()] = appImages + } } return appsForUpdate, nil @@ -378,10 +379,10 @@ func mergeHelmParams(src []v1alpha1.HelmParameter, merge []v1alpha1.HelmParamete return retParams } -// SetHelmImage sets image parameters for a Helm application -func SetHelmImage(app *v1alpha1.Application, newImage *image.ContainerImage) error { - if appType := getApplicationType(app); appType != ApplicationTypeHelm { - return fmt.Errorf("cannot set Helm params on non-Helm application") +// SetHelmImageWithIndex sets image parameters for a Helm application and a specific sourceIndex +func SetHelmImageWithIndex(app *v1alpha1.Application, sourceIndex int, newImage *image.ContainerImage) bool { + if appType := getApplicationTypes(app)[sourceIndex]; appType != ApplicationTypeHelm { + return false } appName := app.GetName() @@ -425,23 +426,24 @@ func SetHelmImage(app *v1alpha1.Application, newImage *image.ContainerImage) err } } - if app.Spec.Source.Helm == nil { - app.Spec.Source.Helm = &v1alpha1.ApplicationSourceHelm{} + if app.Spec.GetSources()[sourceIndex].Helm == nil { + app.Spec.GetSources()[sourceIndex].Helm = &v1alpha1.ApplicationSourceHelm{} } - if app.Spec.Source.Helm.Parameters == nil { - app.Spec.Source.Helm.Parameters = make([]v1alpha1.HelmParameter, 0) + if app.Spec.GetSources()[sourceIndex].Helm.Parameters == nil { + app.Spec.GetSources()[sourceIndex].Helm.Parameters = make([]v1alpha1.HelmParameter, 0) } - app.Spec.Source.Helm.Parameters = mergeHelmParams(app.Spec.Source.Helm.Parameters, mergeParams) + app.Spec.GetSources()[sourceIndex].Helm.Parameters = mergeHelmParams(app.Spec.GetSources()[sourceIndex].Helm.Parameters, mergeParams) - return nil + return true } -// SetKustomizeImage sets a Kustomize image for given application -func SetKustomizeImage(app *v1alpha1.Application, newImage *image.ContainerImage) error { - if appType := getApplicationType(app); appType != ApplicationTypeKustomize { - return fmt.Errorf("cannot set Kustomize image on non-Kustomize application") +// SetKustomizeImageWithIndex sets a Kustomize image for given application +// returns whether that specific source was updated +func SetKustomizeImageWithIndex(app *v1alpha1.Application, sourceIndex int, newImage *image.ContainerImage) bool { + if appType := getApplicationTypes(app)[sourceIndex]; appType != ApplicationTypeKustomize { + return false } var ksImageParam string @@ -454,24 +456,31 @@ func SetKustomizeImage(app *v1alpha1.Application, newImage *image.ContainerImage log.WithContext().AddField("application", app.GetName()).Tracef("Setting Kustomize parameter %s", ksImageParam) - if app.Spec.Source.Kustomize == nil { - app.Spec.Source.Kustomize = &v1alpha1.ApplicationSourceKustomize{} + var source = app.Spec.GetSources()[sourceIndex] + if source.Kustomize == nil { + source.Kustomize = &v1alpha1.ApplicationSourceKustomize{} } - for i, kImg := range app.Spec.Source.Kustomize.Images { + for i, kImg := range source.Kustomize.Images { curr := image.NewFromIdentifier(string(kImg)) override := image.NewFromIdentifier(ksImageParam) if curr.ImageName == override.ImageName { curr.ImageAlias = override.ImageAlias - app.Spec.Source.Kustomize.Images[i] = v1alpha1.KustomizeImage(override.String()) + source.Kustomize.Images[i] = v1alpha1.KustomizeImage(override.String()) } } - app.Spec.Source.Kustomize.MergeImage(v1alpha1.KustomizeImage(ksImageParam)) + source.Kustomize.MergeImage(v1alpha1.KustomizeImage(ksImageParam)) - return nil + if app.Spec.HasMultipleSources() { + app.Spec.Sources[sourceIndex] = source + } else { + app.Spec.Source = &source + } + + return true } // GetImagesFromApplication returns the list of known images for the given application @@ -496,33 +505,50 @@ func GetImagesFromApplication(app *v1alpha1.Application) image.ContainerImageLis return images } -// GetApplicationTypeByName first retrieves application with given appName and -// returns its application type -func GetApplicationTypeByName(client ArgoCD, appName string) (ApplicationType, error) { +// GetApplicationTypesByName first retrieves application with given appName and +// returns its application types +func GetApplicationTypesByName(client ArgoCD, appName string) ([]ApplicationType, error) { app, err := client.GetApplication(context.TODO(), appName) if err != nil { - return ApplicationTypeUnsupported, err + retval := []ApplicationType{ApplicationTypeUnsupported} + return retval, err } - return getApplicationType(app), nil + return getApplicationTypes(app), nil } -// GetApplicationType returns the type of the ArgoCD application -func GetApplicationType(app *v1alpha1.Application) ApplicationType { - return getApplicationType(app) +// GetApplicationTypeForSource returns the type of the ArgoCD application source +func GetApplicationTypeForSource(app *v1alpha1.Application, sourceIndex int) ApplicationType { + return getApplicationTypes(app)[sourceIndex] } -// IsValidApplicationType returns true if we can update the application -func IsValidApplicationType(app *v1alpha1.Application) bool { - return getApplicationType(app) != ApplicationTypeUnsupported +// IsValidApplicationTypeForSource returns true if we can update the application source +func IsValidApplicationTypeForSource(app *v1alpha1.Application, sourceIndex int) bool { + return getApplicationTypes(app)[sourceIndex] != ApplicationTypeUnsupported } // getApplicationType returns the type of the application -func getApplicationType(app *v1alpha1.Application) ApplicationType { - sourceType := app.Status.SourceType +// writebacktargetannotation with the kustomization prefix forces all sources to be handled as kustomization +func getApplicationTypes(app *v1alpha1.Application) []ApplicationType { + kustomizationWriteBack := false + retval := make([]ApplicationType, 0) + if st, set := app.Annotations[common.WriteBackTargetAnnotation]; set && strings.HasPrefix(st, common.KustomizationPrefix) { - sourceType = v1alpha1.ApplicationSourceTypeKustomize + kustomizationWriteBack = true + } + + for _, sourceType := range GetSourceTypes(app.Status) { + if kustomizationWriteBack { + retval = append(retval, ApplicationTypeKustomize) + } else { + retval = append(retval, getApplicationTypeForSourceType(sourceType)) + } } + + return retval +} + +func getApplicationTypeForSourceType(sourceType v1alpha1.ApplicationSourceType) ApplicationType { if sourceType == v1alpha1.ApplicationSourceTypeKustomize { return ApplicationTypeKustomize } else if sourceType == v1alpha1.ApplicationSourceTypeHelm { @@ -545,3 +571,14 @@ func (a ApplicationType) String() string { return "Unknown" } } + +func HasMultipleSourceTypes(status v1alpha1.ApplicationStatus) bool { + return status.SourceTypes != nil && len(status.SourceTypes) > 0 +} + +func GetSourceTypes(status v1alpha1.ApplicationStatus) []v1alpha1.ApplicationSourceType { + if HasMultipleSourceTypes(status) { + return status.SourceTypes + } + return []v1alpha1.ApplicationSourceType{status.SourceType} +} diff --git a/pkg/argocd/argocd_test.go b/pkg/argocd/argocd_test.go index fd5b5ded..050a2bf4 100644 --- a/pkg/argocd/argocd_test.go +++ b/pkg/argocd/argocd_test.go @@ -79,7 +79,7 @@ func Test_GetImagesFromApplication(t *testing.T) { }) } -func Test_GetApplicationType(t *testing.T) { +func Test_GetApplicationTypeForSource(t *testing.T) { t.Run("Get application of type Helm", func(t *testing.T) { application := &v1alpha1.Application{ ObjectMeta: v1.ObjectMeta{ @@ -94,7 +94,7 @@ func Test_GetApplicationType(t *testing.T) { }, }, } - appType := GetApplicationType(application) + appType := GetApplicationTypeForSource(application, 0) assert.Equal(t, ApplicationTypeHelm, appType) assert.Equal(t, "Helm", appType.String()) }) @@ -113,7 +113,7 @@ func Test_GetApplicationType(t *testing.T) { }, }, } - appType := GetApplicationType(application) + appType := GetApplicationTypeForSource(application, 0) assert.Equal(t, ApplicationTypeKustomize, appType) assert.Equal(t, "Kustomize", appType.String()) }) @@ -132,7 +132,7 @@ func Test_GetApplicationType(t *testing.T) { }, }, } - appType := GetApplicationType(application) + appType := GetApplicationTypeForSource(application, 0) assert.Equal(t, ApplicationTypeUnsupported, appType) assert.Equal(t, "Unsupported", appType.String()) }) @@ -154,7 +154,7 @@ func Test_GetApplicationType(t *testing.T) { }, }, } - appType := GetApplicationType(application) + appType := GetApplicationTypeForSource(application, 0) assert.Equal(t, ApplicationTypeKustomize, appType) }) @@ -466,11 +466,11 @@ func Test_SetKustomizeImage(t *testing.T) { }, } img := image.NewFromIdentifier("jannfis/foobar:1.0.1") - err := SetKustomizeImage(app, img) - require.NoError(t, err) - require.NotNil(t, app.Spec.Source.Kustomize) - assert.Len(t, app.Spec.Source.Kustomize.Images, 1) - assert.Equal(t, v1alpha1.KustomizeImage("jannfis/foobar:1.0.1"), app.Spec.Source.Kustomize.Images[0]) + updated := SetKustomizeImageWithIndex(app, 0, img) + assert.True(t, updated) + require.NotNil(t, app.Spec.GetSources()[0].Kustomize) + assert.Len(t, app.Spec.GetSources()[0].Kustomize.Images, 1) + assert.Equal(t, v1alpha1.KustomizeImage("jannfis/foobar:1.0.1"), app.Spec.GetSources()[0].Kustomize.Images[0]) }) t.Run("Test set Kustomize image parameters on Kustomize app with no params set", func(t *testing.T) { @@ -492,11 +492,11 @@ func Test_SetKustomizeImage(t *testing.T) { }, } img := image.NewFromIdentifier("jannfis/foobar:1.0.1") - err := SetKustomizeImage(app, img) - require.NoError(t, err) - require.NotNil(t, app.Spec.Source.Kustomize) - assert.Len(t, app.Spec.Source.Kustomize.Images, 1) - assert.Equal(t, v1alpha1.KustomizeImage("jannfis/foobar:1.0.1"), app.Spec.Source.Kustomize.Images[0]) + updated := SetKustomizeImageWithIndex(app, 0, img) + assert.True(t, updated) + require.NotNil(t, app.Spec.GetSources()[0].Kustomize) + assert.Len(t, app.Spec.GetSources()[0].Kustomize.Images, 1) + assert.Equal(t, v1alpha1.KustomizeImage("jannfis/foobar:1.0.1"), app.Spec.GetSources()[0].Kustomize.Images[0]) }) t.Run("Test set Kustomize image parameters on non-Kustomize app", func(t *testing.T) { @@ -524,8 +524,8 @@ func Test_SetKustomizeImage(t *testing.T) { }, } img := image.NewFromIdentifier("jannfis/foobar:1.0.1") - err := SetKustomizeImage(app, img) - require.Error(t, err) + updated := SetKustomizeImageWithIndex(app, 0, img) + assert.False(t, updated) }) t.Run("Test set Kustomize image parameters with alias name on Kustomize app with param already set", func(t *testing.T) { @@ -556,11 +556,11 @@ func Test_SetKustomizeImage(t *testing.T) { }, } img := image.NewFromIdentifier("foobar=jannfis/foobar:1.0.1") - err := SetKustomizeImage(app, img) - require.NoError(t, err) - require.NotNil(t, app.Spec.Source.Kustomize) - assert.Len(t, app.Spec.Source.Kustomize.Images, 1) - assert.Equal(t, v1alpha1.KustomizeImage("foobar=jannfis/foobar:1.0.1"), app.Spec.Source.Kustomize.Images[0]) + updated := SetKustomizeImageWithIndex(app, 0, img) + assert.True(t, updated) + require.NotNil(t, app.Spec.GetSources()[0].Kustomize) + assert.Len(t, app.Spec.GetSources()[0].Kustomize.Images, 1) + assert.Equal(t, v1alpha1.KustomizeImage("foobar=jannfis/foobar:1.0.1"), app.Spec.GetSources()[0].Kustomize.Images[0]) }) } @@ -604,14 +604,14 @@ func Test_SetHelmImage(t *testing.T) { img := image.NewFromIdentifier("foobar=jannfis/foobar:1.0.1") - err := SetHelmImage(app, img) - require.NoError(t, err) - require.NotNil(t, app.Spec.Source.Helm) - assert.Len(t, app.Spec.Source.Helm.Parameters, 2) + updated := SetHelmImageWithIndex(app, 0, img) + assert.True(t, updated) + require.NotNil(t, app.Spec.GetSources()[0].Helm) + assert.Len(t, app.Spec.GetSources()[0].Helm.Parameters, 2) // Find correct parameter var tagParam v1alpha1.HelmParameter - for _, p := range app.Spec.Source.Helm.Parameters { + for _, p := range app.Spec.GetSources()[0].Helm.Parameters { if p.Name == "image.tag" { tagParam = p break @@ -647,14 +647,14 @@ func Test_SetHelmImage(t *testing.T) { img := image.NewFromIdentifier("foobar=jannfis/foobar:1.0.1") - err := SetHelmImage(app, img) - require.NoError(t, err) - require.NotNil(t, app.Spec.Source.Helm) - assert.Len(t, app.Spec.Source.Helm.Parameters, 2) + updated := SetHelmImageWithIndex(app, 0, img) + assert.True(t, updated) + require.NotNil(t, app.Spec.GetSources()[0].Helm) + assert.Len(t, app.Spec.GetSources()[0].Helm.Parameters, 2) // Find correct parameter var tagParam v1alpha1.HelmParameter - for _, p := range app.Spec.Source.Helm.Parameters { + for _, p := range app.Spec.GetSources()[0].Helm.Parameters { if p.Name == "image.tag" { tagParam = p break @@ -701,14 +701,14 @@ func Test_SetHelmImage(t *testing.T) { img := image.NewFromIdentifier("foobar=jannfis/foobar:1.0.1") - err := SetHelmImage(app, img) - require.NoError(t, err) - require.NotNil(t, app.Spec.Source.Helm) - assert.Len(t, app.Spec.Source.Helm.Parameters, 4) + updated := SetHelmImageWithIndex(app, 0, img) + assert.True(t, updated) + require.NotNil(t, app.Spec.GetSources()[0].Helm) + assert.Len(t, app.Spec.GetSources()[0].Helm.Parameters, 4) // Find correct parameter var tagParam v1alpha1.HelmParameter - for _, p := range app.Spec.Source.Helm.Parameters { + for _, p := range app.Spec.GetSources()[0].Helm.Parameters { if p.Name == "foobar.image.tag" { tagParam = p break @@ -742,8 +742,8 @@ func Test_SetHelmImage(t *testing.T) { img := image.NewFromIdentifier("foobar=jannfis/foobar:1.0.1") - err := SetHelmImage(app, img) - require.Error(t, err) + updated := SetHelmImageWithIndex(app, 0, img) + assert.False(t, updated) }) } @@ -818,7 +818,7 @@ func TestKubernetesClient_UpdateSpec_Conflict(t *testing.T) { require.NoError(t, err) - assert.Equal(t, "https://github.com/argoproj/argocd-example-apps", spec.Source.RepoURL) + assert.Equal(t, "https://github.com/argoproj/argocd-example-apps", spec.GetSources()[0].RepoURL) } func Test_parseImageList(t *testing.T) { diff --git a/pkg/argocd/git.go b/pkg/argocd/git.go index 4f1de571..b96b5c22 100644 --- a/pkg/argocd/git.go +++ b/pkg/argocd/git.go @@ -123,15 +123,15 @@ func TemplateBranchName(branchName string, changeList []ChangeEntry) string { } } -type changeWriter func(app *v1alpha1.Application, wbc *WriteBackConfig, gitC git.Client) (err error, skip bool) +type changeWriter func(app *v1alpha1.Application, wbc *WriteBackConfig, sourceIndex int, gitC git.Client) (err error, skip bool) // commitChanges commits any changes required for updating one or more images // after the UpdateApplication cycle has finished. -func commitChangesGit(app *v1alpha1.Application, wbc *WriteBackConfig, changeList []ChangeEntry, write changeWriter) error { +func commitChangesGit(app *v1alpha1.Application, wbc *WriteBackConfig, sourceIndex int, changeList []ChangeEntry, write changeWriter) error { logCtx := log.WithContext().AddField("application", app.GetName()) - creds, err := wbc.GetCreds(app) + creds, err := wbc.GetCreds(app, sourceIndex) if err != nil { - return fmt.Errorf("could not get creds for repo '%s': %v", wbc.GitRepo, err) + return fmt.Errorf("could not get creds for repo '%s': %v", wbc.GitRepos[sourceIndex], err) } var gitC git.Client if wbc.GitClient == nil { @@ -145,7 +145,7 @@ func commitChangesGit(app *v1alpha1.Application, wbc *WriteBackConfig, changeLis logCtx.Errorf("could not remove temp dir: %v", err) } }() - gitC, err = git.NewClientExt(wbc.GitRepo, tempRoot, creds, false, false, "") + gitC, err = git.NewClientExt(wbc.GitRepos[sourceIndex], tempRoot, creds, false, false, "") if err != nil { return err } @@ -173,7 +173,7 @@ func commitChangesGit(app *v1alpha1.Application, wbc *WriteBackConfig, changeLis // config, or taken from the application spec's targetRevision. If the // target revision is set to the special value HEAD, or is the empty // string, we'll try to resolve it to a branch name. - checkOutBranch := app.Spec.Source.TargetRevision + checkOutBranch := app.Spec.GetSources()[sourceIndex].TargetRevision if wbc.GitBranch != "" { checkOutBranch = wbc.GitBranch } @@ -211,7 +211,7 @@ func commitChangesGit(app *v1alpha1.Application, wbc *WriteBackConfig, changeLis return err } - if err, skip := write(app, wbc, gitC); err != nil { + if err, skip := write(app, wbc, sourceIndex, gitC); err != nil { return err } else if skip { return nil @@ -246,10 +246,10 @@ func commitChangesGit(app *v1alpha1.Application, wbc *WriteBackConfig, changeLis return nil } -func writeOverrides(app *v1alpha1.Application, wbc *WriteBackConfig, gitC git.Client) (err error, skip bool) { - logCtx := log.WithContext().AddField("application", app.GetName()) +func writeOverrides(app *v1alpha1.Application, wbc *WriteBackConfig, sourceIndex int, gitC git.Client) (err error, skip bool) { + logCtx := log.WithContext().AddField("application", app.GetName()).AddField("source", sourceIndex) targetExists := true - targetFile := path.Join(gitC.Root(), wbc.Target) + targetFile := path.Join(gitC.Root(), wbc.Targets[sourceIndex]) _, err = os.Stat(targetFile) if err != nil { if !os.IsNotExist(err) { @@ -269,7 +269,7 @@ func writeOverrides(app *v1alpha1.Application, wbc *WriteBackConfig, gitC git.Cl if err != nil { return err, false } - override, err = marshalParamsOverride(app, originalData) + override, err = marshalParamsOverride(app, sourceIndex, originalData) if err != nil { return } @@ -278,7 +278,7 @@ func writeOverrides(app *v1alpha1.Application, wbc *WriteBackConfig, gitC git.Cl return nil, true } } else { - override, err = marshalParamsOverride(app, nil) + override, err = marshalParamsOverride(app, sourceIndex, nil) if err != nil { return } @@ -292,16 +292,17 @@ func writeOverrides(app *v1alpha1.Application, wbc *WriteBackConfig, gitC git.Cl if !targetExists { err = gitC.Add(targetFile) } + return } var _ changeWriter = writeOverrides // writeKustomization writes any changes required for updating one or more images to a kustomization.yml -func writeKustomization(app *v1alpha1.Application, wbc *WriteBackConfig, gitC git.Client) (err error, skip bool) { +func writeKustomization(app *v1alpha1.Application, wbc *WriteBackConfig, sourceIndex int, gitC git.Client) (err error, skip bool) { logCtx := log.WithContext().AddField("application", app.GetName()) - base := filepath.Join(gitC.Root(), wbc.KustomizeBase) + base := filepath.Join(gitC.Root(), wbc.KustomizeBases[sourceIndex]) logCtx.Infof("updating base %s", base) @@ -310,7 +311,7 @@ func writeKustomization(app *v1alpha1.Application, wbc *WriteBackConfig, gitC gi return fmt.Errorf("could not find kustomization in %s", base), false } - filterFunc, err := imagesFilter(app.Spec.Source.Kustomize.Images) + filterFunc, err := imagesFilter(app.Spec.GetSources()[sourceIndex].Kustomize.Images) if err != nil { return err, false } diff --git a/pkg/argocd/gitcreds.go b/pkg/argocd/gitcreds.go index d7996026..26438e62 100644 --- a/pkg/argocd/gitcreds.go +++ b/pkg/argocd/gitcreds.go @@ -17,36 +17,36 @@ import ( func getGitCredsSource(creds string, kubeClient *kube.KubernetesClient, wbc *WriteBackConfig) (GitCredsSource, error) { switch { case creds == "repocreds": - return func(app *v1alpha1.Application) (git.Creds, error) { - return getCredsFromArgoCD(wbc, kubeClient) + return func(app *v1alpha1.Application, sourceIndex int) (git.Creds, error) { + return getCredsFromArgoCD(wbc, sourceIndex, kubeClient) }, nil case strings.HasPrefix(creds, "secret:"): - return func(app *v1alpha1.Application) (git.Creds, error) { - return getCredsFromSecret(wbc, creds[len("secret:"):], kubeClient) + return func(app *v1alpha1.Application, sourceIndex int) (git.Creds, error) { + return getCredsFromSecret(wbc, creds[len("secret:"):], sourceIndex, kubeClient) }, nil } return nil, fmt.Errorf("unexpected credentials format. Expected 'repocreds' or 'secret:/' but got '%s'", creds) } // getCredsFromArgoCD loads repository credentials from Argo CD settings -func getCredsFromArgoCD(wbc *WriteBackConfig, kubeClient *kube.KubernetesClient) (git.Creds, error) { +func getCredsFromArgoCD(wbc *WriteBackConfig, sourceIndex int, kubeClient *kube.KubernetesClient) (git.Creds, error) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() settingsMgr := settings.NewSettingsManager(ctx, kubeClient.Clientset, kubeClient.Namespace) argocdDB := db.NewDB(kubeClient.Namespace, settingsMgr, kubeClient.Clientset) - repo, err := argocdDB.GetRepository(ctx, wbc.GitRepo) + repo, err := argocdDB.GetRepository(ctx, wbc.GitRepos[sourceIndex]) if err != nil { return nil, err } if !repo.HasCredentials() { - return nil, fmt.Errorf("credentials for '%s' are not configured in Argo CD settings", wbc.GitRepo) + return nil, fmt.Errorf("credentials for '%s' are not configured in Argo CD settings", wbc.GitRepos[sourceIndex]) } return repo.GetGitCreds(nil), nil } // getCredsFromSecret loads repository credentials from secret -func getCredsFromSecret(wbc *WriteBackConfig, credentialsSecret string, kubeClient *kube.KubernetesClient) (git.Creds, error) { +func getCredsFromSecret(wbc *WriteBackConfig, credentialsSecret string, sourceIndex int, kubeClient *kube.KubernetesClient) (git.Creds, error) { var credentials map[string][]byte var err error s := strings.SplitN(credentialsSecret, "/", 2) @@ -59,13 +59,13 @@ func getCredsFromSecret(wbc *WriteBackConfig, credentialsSecret string, kubeClie return nil, fmt.Errorf("secret ref must be in format 'namespace/name', but is '%s'", credentialsSecret) } - if ok, _ := git.IsSSHURL(wbc.GitRepo); ok { + if ok, _ := git.IsSSHURL(wbc.GitRepos[sourceIndex]); ok { var sshPrivateKey []byte if sshPrivateKey, ok = credentials["sshPrivateKey"]; !ok { return nil, fmt.Errorf("invalid secret %s: does not contain field sshPrivateKey", credentialsSecret) } return git.NewSSHCreds(string(sshPrivateKey), "", true), nil - } else if git.IsHTTPSURL(wbc.GitRepo) { + } else if git.IsHTTPSURL(wbc.GitRepos[sourceIndex]) { var username, password []byte if username, ok = credentials["username"]; !ok { return nil, fmt.Errorf("invalid secret %s: does not contain field username", credentialsSecret) diff --git a/pkg/argocd/update.go b/pkg/argocd/update.go index 2910d99d..79e912e8 100644 --- a/pkg/argocd/update.go +++ b/pkg/argocd/update.go @@ -47,7 +47,7 @@ type UpdateConfiguration struct { IgnorePlatforms bool } -type GitCredsSource func(app *v1alpha1.Application) (git.Creds, error) +type GitCredsSource func(app *v1alpha1.Application, sourceIndex int) (git.Creds, error) type WriteBackMethod int @@ -68,9 +68,9 @@ type WriteBackConfig struct { GitCommitUser string GitCommitEmail string GitCommitMessage string - KustomizeBase string - Target string - GitRepo string + KustomizeBases []string + Targets []string + GitRepos []string } // The following are helper structs to only marshal the fields we require @@ -139,7 +139,7 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat var needUpdate bool = false result := ImageUpdaterResult{} - app := updateConf.UpdateApp.Application.GetName() + appName := updateConf.UpdateApp.Application.GetName() changeList := make([]ChangeEntry, 0) // Get all images that are deployed with the current application @@ -156,7 +156,7 @@ 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) + log.WithContext().AddField("application", appName).Debugf("Image '%s' seems not to be live in this application, skipping", applicationImage.ImageName) result.NumSkipped += 1 continue } @@ -171,7 +171,7 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat result.NumImagesConsidered += 1 imgCtx := log.WithContext(). - AddField("application", app). + AddField("application", appName). AddField("registry", updateableImage.RegistryURL). AddField("image_name", updateableImage.ImageName). AddField("image_tag", updateableImage.ImageTag). @@ -201,10 +201,11 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat vc.Strategy = applicationImage.GetParameterUpdateStrategy(updateConf.UpdateApp.Application.Annotations) vc.MatchFunc, vc.MatchArgs = applicationImage.GetParameterMatch(updateConf.UpdateApp.Application.Annotations) vc.IgnoreList = applicationImage.GetParameterIgnoreTags(updateConf.UpdateApp.Application.Annotations) + vc.SourceIndex = applicationImage.GetSourceIndex(updateConf.UpdateApp.Application.Annotations) vc.Options = applicationImage. GetPlatformOptions(updateConf.UpdateApp.Application.Annotations, updateConf.IgnorePlatforms). WithMetadata(vc.Strategy.NeedsMetadata()). - WithLogger(imgCtx.AddField("application", app)) + WithLogger(imgCtx.AddField("application", appName)) // If a strategy needs meta-data and tagsortmode is set for the // registry, let the user know. @@ -325,29 +326,42 @@ func UpdateApplication(updateConf *UpdateConfiguration, state *SyncIterationStat } if needUpdate { - logCtx := log.WithContext().AddField("application", app) + logCtx := log.WithContext().AddField("application", appName) log.Debugf("Using commit message: %s", wbc.GitCommitMessage) if !updateConf.DryRun { - logCtx.Infof("Committing %d parameter update(s) for application %s", result.NumImagesUpdated, app) - err := commitChangesLocked(&updateConf.UpdateApp.Application, wbc, state, changeList) - if err != nil { - logCtx.Errorf("Could not update application spec: %v", err) - result.NumErrors += 1 - result.NumImagesUpdated = 0 + logCtx.Infof("Committing %d parameter update(s) for application %s", result.NumImagesUpdated, appName) + app := &updateConf.UpdateApp.Application + var iterations int + + // if writing back to git, each source might have its own target or kustomize base, so we have to iterate + // once per source. Otherwise, we only have to writeback once. + if wbc.Method == WriteBackGit { + iterations = len(app.Spec.GetSources()) } else { - logCtx.Infof("Successfully updated the live application spec") - if !updateConf.DisableKubeEvents && updateConf.KubeClient != nil { - annotations := map[string]string{} - for i, c := range changeList { - annotations[fmt.Sprintf("argocd-image-updater.image-%d/full-image-name", i)] = c.Image.GetFullNameWithoutTag() - annotations[fmt.Sprintf("argocd-image-updater.image-%d/image-name", i)] = c.Image.ImageName - annotations[fmt.Sprintf("argocd-image-updater.image-%d/old-tag", i)] = c.OldTag.String() - annotations[fmt.Sprintf("argocd-image-updater.image-%d/new-tag", i)] = c.NewTag.String() - } - message := fmt.Sprintf("Successfully updated application '%s'", app) - _, err = updateConf.KubeClient.CreateApplicationEvent(&updateConf.UpdateApp.Application, "ImagesUpdated", message, annotations) - if err != nil { - logCtx.Warnf("Event could not be sent: %v", err) + iterations = 1 + } + + for sourceIndex := 0; sourceIndex < iterations; sourceIndex++ { + err := commitChangesLocked(app, wbc, sourceIndex, state, changeList) + if err != nil { + logCtx.Errorf("Could not update application spec: %v", err) + result.NumErrors += 1 + result.NumImagesUpdated = 0 + } else { + logCtx.Infof("Successfully updated the live application spec") + if !updateConf.DisableKubeEvents && updateConf.KubeClient != nil { + annotations := map[string]string{} + for i, c := range changeList { + annotations[fmt.Sprintf("argocd-image-updater.image-%d/full-image-name", i)] = c.Image.GetFullNameWithoutTag() + annotations[fmt.Sprintf("argocd-image-updater.image-%d/image-name", i)] = c.Image.ImageName + annotations[fmt.Sprintf("argocd-image-updater.image-%d/old-tag", i)] = c.OldTag.String() + annotations[fmt.Sprintf("argocd-image-updater.image-%d/new-tag", i)] = c.NewTag.String() + } + message := fmt.Sprintf("Successfully updated application '%s'", app) + _, err = updateConf.KubeClient.CreateApplicationEvent(app, "ImagesUpdated", message, annotations) + if err != nil { + logCtx.Warnf("Event could not be sent: %v", err) + } } } } @@ -365,34 +379,65 @@ func needsUpdate(updateableImage *image.ContainerImage, applicationImage *image. } func setAppImage(app *v1alpha1.Application, img *image.ContainerImage) error { - var err error - if appType := GetApplicationType(app); appType == ApplicationTypeKustomize { - err = SetKustomizeImage(app, img) - } else if appType == ApplicationTypeHelm { - err = SetHelmImage(app, img) - } else { - err = fmt.Errorf("could not update application %s - neither Helm nor Kustomize application", app) + logCtx := img.LogContext() + imageUpdated := false + imageUpdatable := false + for i, appType := range getApplicationTypes(app) { + logCtx.Tracef("Examining source index %d for update", i) + if imgSourceIndex := img.GetSourceIndex(app.Annotations); imgSourceIndex == image.SourceIndexAll || i == int(imgSourceIndex) { + logCtx.Debugf("Image source index %d - attempting to update source", i) + if appType == ApplicationTypeKustomize { + logCtx.Debugf("Image source index %d - attempting to update source as Kustomize application type", i) + imageUpdatable = true + if imageSourceUpdated := SetKustomizeImageWithIndex(app, i, img); imageSourceUpdated { + logCtx.Debugf("Image source index %d - successfully updated source as Kustomize image", i) + imageUpdated = true + } else { + logCtx.Debugf("Image source index %d - unable to update Kustomize images - no updatable images found", i) + } + } else if appType == ApplicationTypeHelm { + logCtx.Debugf("Image source index %d - attempting to update source as Helm application type", i) + imageUpdatable = true + if imageSourceUpdated := SetHelmImageWithIndex(app, i, img); imageSourceUpdated { + logCtx.Debugf("Image source index %d - successfully updated source as Helm parameters", i) + imageUpdated = true + } else { + logCtx.Debugf("Image source index %d - unable to update source as helm parameters - no updatable images found", i) + } + } else { + logCtx.Debugf("Image source index %d - skipping update to source due to unsupported application type", i) + } + } else { + logCtx.Tracef("Skipping update of source index %d due to image source index %d", i, imgSourceIndex) + } } - return err + + if !imageUpdatable { + return fmt.Errorf("could not update application %s - no Helm or Kustomize sources found", app) + } else if imageUpdatable && !imageUpdated { + return fmt.Errorf("could not update application %s - image does not appear to be used in any eligible sources", app) + } + + return nil } // marshalParamsOverride marshals the parameter overrides of a given application // into YAML bytes -func marshalParamsOverride(app *v1alpha1.Application, originalData []byte) ([]byte, error) { +func marshalParamsOverride(app *v1alpha1.Application, sourceIndex int, originalData []byte) ([]byte, error) { var override []byte var err error - appType := GetApplicationType(app) + appType := getApplicationTypes(app)[sourceIndex] switch appType { case ApplicationTypeKustomize: - if app.Spec.Source.Kustomize == nil { + if app.Spec.GetSources()[sourceIndex].Kustomize == nil { return []byte{}, nil } var params kustomizeOverride newParams := kustomizeOverride{ Kustomize: kustomizeImages{ - Images: &app.Spec.Source.Kustomize.Images, + Images: &app.Spec.GetSources()[sourceIndex].Kustomize.Images, }, } @@ -408,13 +453,13 @@ func marshalParamsOverride(app *v1alpha1.Application, originalData []byte) ([]by mergeKustomizeOverride(¶ms, &newParams) override, err = yaml.Marshal(params) case ApplicationTypeHelm: - if app.Spec.Source.Helm == nil { + if app.Spec.GetSources()[sourceIndex].Helm == nil { return []byte{}, nil } var params helmOverride newParams := helmOverride{ Helm: helmParameters{ - Parameters: app.Spec.Source.Helm.Parameters, + Parameters: app.Spec.GetSources()[sourceIndex].Helm.Parameters, }, } @@ -429,8 +474,8 @@ func marshalParamsOverride(app *v1alpha1.Application, originalData []byte) ([]by } mergeHelmOverride(¶ms, &newParams) override, err = yaml.Marshal(params) - default: - err = fmt.Errorf("unsupported application type") + //default: + // err = fmt.Errorf("unsupported application type") } if err != nil { return nil, err @@ -466,7 +511,7 @@ func getWriteBackConfig(app *v1alpha1.Application, kubeClient *kube.KubernetesCl // Default write-back is to use Argo CD API wbc.Method = WriteBackApplication wbc.ArgoClient = argoClient - wbc.Target = parseDefaultTarget(app.Name, app.Spec.Source.Path) + wbc.Targets = parseDefaultTargets(app.Name, app.Spec.GetSources()) // If we have no update method, just return our default method, ok := app.Annotations[common.WriteBackMethodAnnotation] @@ -486,10 +531,15 @@ func getWriteBackConfig(app *v1alpha1.Application, kubeClient *kube.KubernetesCl case "git": wbc.Method = WriteBackGit if target, ok := app.Annotations[common.WriteBackTargetAnnotation]; ok && strings.HasPrefix(target, common.KustomizationPrefix) { - wbc.KustomizeBase = parseTarget(target, app.Spec.Source.Path) + wbc.KustomizeBases = parseTargets(target, app.Spec.GetSources()) + } else { + wbc.KustomizeBases = make([]string, len(app.Spec.GetSources())) + for i := 0; i < len(app.Spec.GetSources()); i++ { + wbc.KustomizeBases[i] = "" + } } - if err := parseGitConfig(app, kubeClient, wbc, creds); err != nil { - return nil, err + if credErrors := parseGitConfig(app, kubeClient, wbc, creds); len(credErrors) > 0 { + return nil, fmt.Errorf("Errors parsing git credentials: %s", method) } default: return nil, fmt.Errorf("invalid update mechanism: %s", method) @@ -498,60 +548,76 @@ func getWriteBackConfig(app *v1alpha1.Application, kubeClient *kube.KubernetesCl return wbc, nil } -func parseDefaultTarget(appName string, path string) string { - defaultTargetFile := fmt.Sprintf(common.DefaultTargetFilePattern, appName) - - return filepath.Join(path, defaultTargetFile) +func parseDefaultTargets(appName string, sources v1alpha1.ApplicationSources) []string { + retval := make([]string, len(sources)) + for i, source := range sources { + defaultTargetFile := fmt.Sprintf(common.DefaultTargetFilePattern, appName, i) + retval[i] = filepath.Join(source.Path, defaultTargetFile) + } + return retval } -func parseTarget(target string, sourcePath string) (kustomizeBase string) { - if target == common.KustomizationPrefix { - return filepath.Join(sourcePath, ".") - } else if base := target[len(common.KustomizationPrefix)+1:]; strings.HasPrefix(base, "/") { - return base[1:] - } else { - return filepath.Join(sourcePath, base) +func parseTargets(target string, sources v1alpha1.ApplicationSources) (kustomizeBases []string) { + retval := make([]string, len(sources)) + for i, source := range sources { + if target == common.KustomizationPrefix { + retval[i] = filepath.Join(source.Path, ".") + } else if base := target[len(common.KustomizationPrefix)+1:]; strings.HasPrefix(base, "/") { + retval[i] = base[1:] + } else { + retval[i] = filepath.Join(source.Path, base) + } } + + return retval } -func parseGitConfig(app *v1alpha1.Application, kubeClient *kube.KubernetesClient, wbc *WriteBackConfig, creds string) error { +func parseGitConfig(app *v1alpha1.Application, kubeClient *kube.KubernetesClient, wbc *WriteBackConfig, creds string) []error { + errors := make([]error, 0) branch, ok := app.Annotations[common.GitBranchAnnotation] if ok { branches := strings.Split(strings.TrimSpace(branch), ":") if len(branches) > 2 { - return fmt.Errorf("invalid format for git-branch annotation: %v", branch) + errors = append(errors, fmt.Errorf("invalid format for git-branch annotation: %v", branch)) } wbc.GitBranch = branches[0] if len(branches) == 2 { wbc.GitWriteBranch = branches[1] } } - wbc.GitRepo = app.Spec.Source.RepoURL - repo, ok := app.Annotations[common.GitRepositoryAnnotation] - if ok { - wbc.GitRepo = repo - } - credsSource, err := getGitCredsSource(creds, kubeClient, wbc) - if err != nil { - return fmt.Errorf("invalid git credentials source: %v", err) + sources := app.Spec.GetSources() + wbc.GitRepos = make([]string, len(sources)) + for i, source := range sources { + wbc.GitRepos[i] = source.RepoURL + repo, ok := app.Annotations[common.GitRepositoryAnnotation] + if ok { + wbc.GitRepos[i] = repo + } + credSource, err := getGitCredsSource(creds, kubeClient, wbc) + if err != nil { + errors = append(errors, fmt.Errorf("invalid git credentials at source %d: %v", i, err)) + wbc.GetCreds = nil + } else { + wbc.GetCreds = credSource + } + } - wbc.GetCreds = credsSource - return nil + return errors } -func commitChangesLocked(app *v1alpha1.Application, wbc *WriteBackConfig, state *SyncIterationState, changeList []ChangeEntry) error { +func commitChangesLocked(app *v1alpha1.Application, wbc *WriteBackConfig, sourceIndex int, state *SyncIterationState, changeList []ChangeEntry) error { if wbc.RequiresLocking() { - lock := state.GetRepositoryLock(wbc.GitRepo) + lock := state.GetRepositoryLock(wbc.GitRepos[sourceIndex]) lock.Lock() defer lock.Unlock() } - return commitChanges(app, wbc, changeList) + return commitChanges(app, wbc, sourceIndex, changeList) } // commitChanges commits any changes required for updating one or more images // after the UpdateApplication cycle has finished. -func commitChanges(app *v1alpha1.Application, wbc *WriteBackConfig, changeList []ChangeEntry) error { +func commitChanges(app *v1alpha1.Application, wbc *WriteBackConfig, sourceIndex int, changeList []ChangeEntry) error { switch wbc.Method { case WriteBackApplication: _, err := wbc.ArgoClient.UpdateSpec(context.TODO(), &application.ApplicationUpdateSpecRequest{ @@ -563,10 +629,10 @@ func commitChanges(app *v1alpha1.Application, wbc *WriteBackConfig, changeList [ } case WriteBackGit: // if the kustomize base is set, the target is a kustomization - if wbc.KustomizeBase != "" { - return commitChangesGit(app, wbc, changeList, writeKustomization) + if wbc.KustomizeBases[sourceIndex] != "" { + return commitChangesGit(app, wbc, sourceIndex, changeList, writeKustomization) } - return commitChangesGit(app, wbc, changeList, writeOverrides) + return commitChangesGit(app, wbc, sourceIndex, changeList, writeOverrides) default: return fmt.Errorf("unknown write back method set: %d", wbc.Method) } diff --git a/pkg/argocd/update_test.go b/pkg/argocd/update_test.go index 6517025b..0e1fb407 100644 --- a/pkg/argocd/update_test.go +++ b/pkg/argocd/update_test.go @@ -1099,7 +1099,7 @@ kustomize: images: - baz `) - yaml, err := marshalParamsOverride(&app, originalData) + yaml, err := marshalParamsOverride(&app, 0, originalData) require.NoError(t, err) assert.NotEmpty(t, yaml) assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(yaml))) @@ -1125,7 +1125,7 @@ kustomize: }, } - yaml, err := marshalParamsOverride(&app, nil) + yaml, err := marshalParamsOverride(&app, 0, nil) require.NoError(t, err) assert.Empty(t, yaml) assert.Equal(t, "", strings.TrimSpace(string(yaml))) @@ -1185,7 +1185,7 @@ helm: value: baz forcestring: false `) - yaml, err := marshalParamsOverride(&app, originalData) + yaml, err := marshalParamsOverride(&app, 0, originalData) require.NoError(t, err) assert.NotEmpty(t, yaml) assert.Equal(t, strings.TrimSpace(strings.ReplaceAll(expected, "\t", " ")), strings.TrimSpace(string(yaml))) @@ -1211,7 +1211,7 @@ helm: }, } - yaml, err := marshalParamsOverride(&app, nil) + yaml, err := marshalParamsOverride(&app, 0, nil) require.NoError(t, err) assert.Empty(t, yaml) }) @@ -1242,8 +1242,10 @@ helm: }, } - _, err := marshalParamsOverride(&app, nil) - assert.Error(t, err) + _, err := marshalParamsOverride(&app, 0, nil) + // This condition should not result in an error because it could be possible to have multiple source types + // within the same applicaiton + assert.NoError(t, err) }) } @@ -1412,7 +1414,7 @@ func Test_GetWriteBackConfig(t *testing.T) { require.NoError(t, err) require.NotNil(t, wbc) assert.Equal(t, wbc.Method, WriteBackGit) - assert.Equal(t, wbc.KustomizeBase, "config/bar") + assert.Equal(t, wbc.KustomizeBases[0], "config/bar") }) t.Run("Default write-back config - argocd", func(t *testing.T) { @@ -1514,7 +1516,7 @@ func Test_GetGitCreds(t *testing.T) { wbc, err := getWriteBackConfig(&app, &kubeClient, &argoClient) require.NoError(t, err) - creds, err := wbc.GetCreds(&app) + creds, err := wbc.GetCreds(&app, 0) require.NoError(t, err) require.NotNil(t, creds) // Must have HTTPS creds @@ -1552,7 +1554,7 @@ func Test_GetGitCreds(t *testing.T) { wbc, err := getWriteBackConfig(&app, &kubeClient, &argoClient) require.NoError(t, err) - creds, err := wbc.GetCreds(&app) + creds, err := wbc.GetCreds(&app, 0) require.NoError(t, err) require.NotNil(t, creds) // Must have SSH creds @@ -1604,7 +1606,7 @@ func Test_GetGitCreds(t *testing.T) { wbc, err := getWriteBackConfig(&app, &kubeClient, &argoClient) require.NoError(t, err) - creds, err := wbc.GetCreds(&app) + creds, err := wbc.GetCreds(&app, 0) require.NoError(t, err) require.NotNil(t, creds) // Must have HTTPS creds @@ -1642,7 +1644,7 @@ func Test_GetGitCreds(t *testing.T) { wbc, err := getWriteBackConfig(&app, &kubeClient, &argoClient) require.NoError(t, err) - creds, err := wbc.GetCreds(&app) + creds, err := wbc.GetCreds(&app, 0) require.Error(t, err) require.Nil(t, creds) }) @@ -1678,7 +1680,7 @@ func Test_GetGitCreds(t *testing.T) { wbc, err := getWriteBackConfig(&app, &kubeClient, &argoClient) require.NoError(t, err) - creds, err := wbc.GetCreds(&app) + creds, err := wbc.GetCreds(&app, 0) require.Error(t, err) require.Nil(t, creds) }) @@ -1714,7 +1716,7 @@ func Test_GetGitCreds(t *testing.T) { wbc, err := getWriteBackConfig(&app, &kubeClient, &argoClient) require.NoError(t, err) - creds, err := wbc.GetCreds(&app) + creds, err := wbc.GetCreds(&app, 0) require.Error(t, err) require.Nil(t, creds) }) @@ -1751,9 +1753,9 @@ func Test_GetGitCreds(t *testing.T) { wbc, err := getWriteBackConfig(&app, &kubeClient, &argoClient) require.NoError(t, err) - require.Equal(t, wbc.GitRepo, "git@github.com:example/example.git") + require.Equal(t, wbc.GitRepos[0], "git@github.com:example/example.git") - creds, err := wbc.GetCreds(&app) + creds, err := wbc.GetCreds(&app, 0) require.NoError(t, err) require.NotNil(t, creds) // Must have SSH creds @@ -1804,7 +1806,7 @@ func Test_CommitUpdates(t *testing.T) { require.NoError(t, err) wbc.GitClient = gitMock - err = commitChanges(&app, wbc, nil) + err = commitChanges(&app, wbc, 0, nil) assert.NoError(t, err) }) @@ -1824,7 +1826,7 @@ func Test_CommitUpdates(t *testing.T) { wbc.GitClient = gitMock wbc.GitBranch = "mybranch" - err = commitChanges(&app, wbc, nil) + err = commitChanges(&app, wbc, 0, nil) assert.NoError(t, err) }) @@ -1845,7 +1847,7 @@ func Test_CommitUpdates(t *testing.T) { app.Spec.Source.TargetRevision = "HEAD" wbc.GitBranch = "" - err = commitChanges(app, wbc, nil) + err = commitChanges(app, wbc, 0, nil) assert.NoError(t, err) }) @@ -1873,7 +1875,7 @@ func Test_CommitUpdates(t *testing.T) { } gitMock.On("Checkout", TemplateBranchName(wbc.GitWriteBranch, cl)).Return(nil) - err = commitChanges(&app, wbc, cl) + err = commitChanges(&app, wbc, 0, cl) assert.NoError(t, err) }) @@ -1886,7 +1888,7 @@ func Test_CommitUpdates(t *testing.T) { }} gitMock, dir, cleanup := mockGit(t) defer cleanup() - of := filepath.Join(dir, ".argocd-source-testapp.yaml") + of := filepath.Join(dir, ".argocd-source-testapp-0.yaml") assert.NoError(t, os.WriteFile(of, []byte(` helm: parameters: @@ -1908,7 +1910,7 @@ helm: app.Spec.Source.TargetRevision = "HEAD" wbc.GitBranch = "" - err = commitChanges(app, wbc, nil) + err = commitChanges(app, wbc, 0, nil) assert.NoError(t, err) override, err := os.ReadFile(of) assert.NoError(t, err) @@ -1954,7 +1956,7 @@ replacements: [] app.Spec.Source.TargetRevision = "HEAD" wbc.GitBranch = "" - err = commitChanges(app, wbc, nil) + err = commitChanges(app, wbc, 0, nil) assert.NoError(t, err) kust, err := os.ReadFile(kf) assert.NoError(t, err) @@ -1973,7 +1975,7 @@ replacements: [] // test the merge case too app.Spec.Source.Kustomize.Images = v1alpha1.KustomizeImages{"foo:123", "bar=qux"} - err = commitChanges(app, wbc, nil) + err = commitChanges(app, wbc, 0, nil) assert.NoError(t, err) kust, err = os.ReadFile(kf) assert.NoError(t, err) @@ -2012,7 +2014,7 @@ replacements: [] wbc.GitCommitUser = "someone" wbc.GitCommitEmail = "someone@example.com" - err = commitChanges(app, wbc, nil) + err = commitChanges(app, wbc, 0, nil) assert.NoError(t, err) }) @@ -2039,7 +2041,7 @@ replacements: [] wbc.GitCommitUser = "someone" wbc.GitCommitEmail = "someone@example.com" - err = commitChanges(app, wbc, nil) + err = commitChanges(app, wbc, 0, nil) assert.Errorf(t, err, "could not configure git") }) @@ -2054,7 +2056,7 @@ replacements: [] require.NoError(t, err) wbc.GitClient = gitMock - err = commitChanges(&app, wbc, nil) + err = commitChanges(&app, wbc, 0, nil) assert.Errorf(t, err, "cannot init") }) @@ -2069,7 +2071,7 @@ replacements: [] require.NoError(t, err) wbc.GitClient = gitMock - err = commitChanges(&app, wbc, nil) + err = commitChanges(&app, wbc, 0, nil) assert.Errorf(t, err, "cannot init") }) t.Run("Cannot checkout", func(t *testing.T) { @@ -2083,7 +2085,7 @@ replacements: [] require.NoError(t, err) wbc.GitClient = gitMock - err = commitChanges(&app, wbc, nil) + err = commitChanges(&app, wbc, 0, nil) assert.Errorf(t, err, "cannot checkout") }) @@ -2098,7 +2100,7 @@ replacements: [] require.NoError(t, err) wbc.GitClient = gitMock - err = commitChanges(&app, wbc, nil) + err = commitChanges(&app, wbc, 0, nil) assert.Errorf(t, err, "cannot commit") }) @@ -2113,7 +2115,7 @@ replacements: [] require.NoError(t, err) wbc.GitClient = gitMock - err = commitChanges(&app, wbc, nil) + err = commitChanges(&app, wbc, 0, nil) assert.Errorf(t, err, "cannot push") }) @@ -2132,7 +2134,7 @@ replacements: [] app.Spec.Source.TargetRevision = "HEAD" wbc.GitBranch = "" - err = commitChanges(app, wbc, nil) + err = commitChanges(app, wbc, 0, nil) assert.Errorf(t, err, "failed to resolve ref") }) } @@ -2156,7 +2158,9 @@ func Test_parseTarget(t *testing.T) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, parseTarget(tt.target, tt.path)) + sources := make(v1alpha1.ApplicationSources, 1) + sources[0].Path = tt.path + assert.Equal(t, tt.expected, parseTargets(tt.target, sources)[0]) }) } } diff --git a/pkg/common/constants.go b/pkg/common/constants.go index 5abf6da6..50336225 100644 --- a/pkg/common/constants.go +++ b/pkg/common/constants.go @@ -35,6 +35,7 @@ const ( UpdateStrategyAnnotation = ImageUpdaterAnnotationPrefix + "/%s.update-strategy" PullSecretAnnotation = ImageUpdaterAnnotationPrefix + "/%s.pull-secret" PlatformsAnnotation = ImageUpdaterAnnotationPrefix + "/%s.platforms" + SourceIndexAnnotation = ImageUpdaterAnnotationPrefix + "/%s.source-index" ) // Application-wide update strategy related annotations @@ -56,7 +57,7 @@ const ( ) // DefaultTargetFilePattern configurations related to the write-back functionality -const DefaultTargetFilePattern = ".argocd-source-%s.yaml" +const DefaultTargetFilePattern = ".argocd-source-%s-%d.yaml" // The default Git commit message's template const DefaultGitCommitMessage = `build: automatic update of {{ .AppName }} diff --git a/pkg/image/options.go b/pkg/image/options.go index af6d12a0..8360f53a 100644 --- a/pkg/image/options.go +++ b/pkg/image/options.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "runtime" + "strconv" "strings" "github.com/argoproj-labs/argocd-image-updater/pkg/common" @@ -71,7 +72,7 @@ func (img *ContainerImage) HasForceUpdateOptionAnnotation(annotations map[string return forceUpdateVal == "true" } -// GetParameterSort gets and validates the value for the sort option for the +// GetParameterUpdateStrategy gets and validates the value for the update strategy for the // image from a set of annotations func (img *ContainerImage) GetParameterUpdateStrategy(annotations map[string]string) UpdateStrategy { updateStrategyAnnotations := []string{ @@ -205,6 +206,35 @@ func (img *ContainerImage) GetParameterPullSecret(annotations map[string]string) return credSrc } +func (img *ContainerImage) GetSourceIndex(annotations map[string]string) SourceIndex { + sourceIndexAnnotation := fmt.Sprintf(common.SourceIndexAnnotation, img.normalizedSymbolicName()) + + var sourceIndexVal = "" + if val, ok := annotations[sourceIndexAnnotation]; ok { + sourceIndexVal = val + } + + logCtx := img.LogContext() + if sourceIndexVal == "" { + logCtx.Tracef("No source index found. Defaulting to all.") + // Default is sort by version + return SourceIndexAll + } + logCtx.Tracef("Found source index %s", sourceIndexVal) + return img.ParseSourceIndex(sourceIndexVal) +} + +func (img *ContainerImage) ParseSourceIndex(sourceIndexVal string) SourceIndex { + logCtx := img.LogContext() + + if num, err := strconv.ParseInt(sourceIndexVal, 10, 0); err == nil { + return SourceIndex(num) + } else { + logCtx.Warnf("Invalid sourceIndex. Defaulting to all. Unexpected results may occur.") + return SourceIndexAll + } +} + // GetParameterIgnoreTags retrieves a list of tags to ignore from a comma-separated string func (img *ContainerImage) GetParameterIgnoreTags(annotations map[string]string) []string { ignoreTagsAnnotations := []string{ diff --git a/pkg/image/version.go b/pkg/image/version.go index 7613e4b9..d1285195 100644 --- a/pkg/image/version.go +++ b/pkg/image/version.go @@ -24,6 +24,12 @@ const ( StrategyDigest UpdateStrategy = 3 ) +type SourceIndex int + +const ( + SourceIndexAll SourceIndex = -1 +) + func (us UpdateStrategy) String() string { switch us { case StrategySemVer: @@ -53,12 +59,13 @@ const ( // VersionConstraint defines a constraint for comparing versions type VersionConstraint struct { - Constraint string - MatchFunc MatchFuncFn - MatchArgs interface{} - IgnoreList []string - Strategy UpdateStrategy - Options *options.ManifestOptions + Constraint string + MatchFunc MatchFuncFn + MatchArgs interface{} + IgnoreList []string + Strategy UpdateStrategy + Options *options.ManifestOptions + SourceIndex SourceIndex } type MatchFuncFn func(tagName string, pattern interface{}) bool @@ -70,9 +77,10 @@ func (vc *VersionConstraint) String() string { func NewVersionConstraint() *VersionConstraint { return &VersionConstraint{ - MatchFunc: MatchFuncNone, - Strategy: StrategySemVer, - Options: options.NewManifestOptions(), + MatchFunc: MatchFuncNone, + Strategy: StrategySemVer, + SourceIndex: SourceIndexAll, + Options: options.NewManifestOptions(), } }