diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index 26039030..f8925d4e 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -82,6 +82,7 @@ const ( ApplicationTypeUnsupported ApplicationType = 0 ApplicationTypeHelm ApplicationType = 1 ApplicationTypeKustomize ApplicationType = 2 + ApplicationTypePlugin ApplicationType = 3 ) // Basic wrapper struct for ArgoCD client options @@ -478,6 +479,139 @@ func SetKustomizeImage(app *v1alpha1.Application, newImage *image.ContainerImage return nil } +// SetPluginImage sets the image parameters for a Plugin type application +func SetPluginImage(app *v1alpha1.Application, newImage *image.ContainerImage) error { + if appType := getApplicationType(app); appType != ApplicationTypePlugin { + return fmt.Errorf("cannot set Helm arguments on non-Plugin application") + } + + appName := app.GetName() + + var hpImageName, hpImageTag, hpImageSpec string + + hpImageSpec = newImage.GetParameterHelmImageSpec(app.Annotations) + hpImageName = newImage.GetParameterHelmImageName(app.Annotations) + hpImageTag = newImage.GetParameterHelmImageTag(app.Annotations) + + if hpImageSpec == "" { + if hpImageName == "" { + hpImageName = common.DefaultHelmImageName + } + if hpImageTag == "" { + hpImageTag = common.DefaultHelmImageTag + } + } + + log.WithContext(). + AddField("application", appName). + AddField("image", newImage.GetFullNameWithoutTag()). + Debugf("target parameters: image-spec=%s image-name=%s, image-tag=%s", hpImageSpec, hpImageName, hpImageTag) + + // Initialize a map to hold the merged parameters + mergeParams := make(map[string]string) + + // If image-spec is set, it overrides any image-name and image-tag parameters + if hpImageSpec != "" { + mergeParams[hpImageSpec] = newImage.GetFullNameWithTag() + } else { + if hpImageName != "" { + mergeParams[hpImageName] = newImage.GetFullNameWithoutTag() + } + if hpImageTag != "" { + mergeParams[hpImageTag] = newImage.GetTagWithDigest() + } + } + + if app.Spec.Source.Plugin == nil { + app.Spec.Source.Plugin = &v1alpha1.ApplicationSourcePlugin{} + } + + if app.Spec.Source.Plugin.Env == nil { + app.Spec.Source.Plugin.Env = make([]*v1alpha1.EnvEntry, 0) + } + + // Retrieve the existing HELM_ARGS value + helmArgs := "" + for _, env := range app.Spec.Source.Plugin.Env { + if env.Name == common.DefaultPluginEnvVarName { + helmArgs = env.Value + break + } + } + + // Parse the existing HELM_ARGS into parameters and other arguments + existingParams, otherArgs := parseHelmArgs(helmArgs) + + // Merge the new parameters with the existing ones + for key, value := range mergeParams { + existingValue, exists := existingParams[key] + if !exists || existingValue != value { + existingParams[key] = value + } + } + + // Build the new HELM_ARGS string + newHelmArgs := buildHelmArgs(existingParams, otherArgs) + + // If there are no changes in HELM_ARGS, return early + if newHelmArgs == helmArgs { + return nil + } + + // Update the HELM_ARGS environment variable + found := false + for _, env := range app.Spec.Source.Plugin.Env { + if env.Name == common.DefaultPluginEnvVarName { + env.Value = newHelmArgs + found = true + break + } + } + + // If HELM_ARGS was not found, add it to the environment variables + if !found { + app.Spec.Source.Plugin.Env = append(app.Spec.Source.Plugin.Env, &v1alpha1.EnvEntry{Name: common.DefaultPluginEnvVarName, Value: newHelmArgs}) + } + + return nil +} + +// parseHelmArgs parses a HELM_ARGS string into a map of parameters and a slice of other arguments +func parseHelmArgs(helmArgs string) (map[string]string, []string) { + params := make(map[string]string) // Map to hold --set parameters + var otherArgs []string // Slice to hold other arguments + + // Split the HELM_ARGS string into individual arguments + args := strings.Fields(helmArgs) + for i := 0; i < len(args); i++ { + // Check if the argument is a --set parameter + if args[i] == "--set" && i+1 < len(args) { + // Split the --set parameter into key and value + parts := strings.SplitN(args[i+1], "=", 2) + if len(parts) == 2 { + // Add the key and value to the params map + params[parts[0]] = parts[1] + } + i++ // Skip the next argument as it has been processed + } else { + // Add non --set arguments to otherArgs slice + otherArgs = append(otherArgs, args[i]) + } + } + return params, otherArgs // Return the parsed parameters and other arguments +} + +// buildHelmArgs constructs a HELM_ARGS string from a map of parameters and a slice of other arguments +func buildHelmArgs(params map[string]string, otherArgs []string) string { + args := otherArgs // Start with other arguments + for key, value := range params { + // Append each --set parameter to the arguments + args = append(args, fmt.Sprintf("--set %s=%s", key, value)) + } + // Join all arguments into a single HELM_ARGS string + return strings.Join(args, " ") +} + // GetImagesFromApplication returns the list of known images for the given application func GetImagesFromApplication(app *v1alpha1.Application) image.ContainerImageList { images := make(image.ContainerImageList, 0) @@ -556,6 +690,8 @@ func getApplicationType(app *v1alpha1.Application) ApplicationType { return ApplicationTypeKustomize } else if sourceType == v1alpha1.ApplicationSourceTypeHelm { return ApplicationTypeHelm + } else if sourceType == v1alpha1.ApplicationSourceTypePlugin { + return ApplicationTypePlugin } else { return ApplicationTypeUnsupported } @@ -609,6 +745,8 @@ func (a ApplicationType) String() string { return "Kustomize" case ApplicationTypeHelm: return "Helm" + case ApplicationTypePlugin: + return "Plugin" case ApplicationTypeUnsupported: return "Unsupported" default: diff --git a/pkg/argocd/argocd_test.go b/pkg/argocd/argocd_test.go index cbe5b078..e0f713a9 100644 --- a/pkg/argocd/argocd_test.go +++ b/pkg/argocd/argocd_test.go @@ -177,7 +177,7 @@ func Test_GetApplicationType(t *testing.T) { assert.Equal(t, "Kustomize", appType.String()) }) - t.Run("Get application of unknown Type", func(t *testing.T) { + t.Run("Get application of plugin Type", func(t *testing.T) { application := &v1alpha1.Application{ ObjectMeta: v1.ObjectMeta{ Name: "test-app", @@ -192,8 +192,8 @@ func Test_GetApplicationType(t *testing.T) { }, } appType := GetApplicationType(application) - assert.Equal(t, ApplicationTypeUnsupported, appType) - assert.Equal(t, "Unsupported", appType.String()) + assert.Equal(t, ApplicationTypePlugin, appType) + assert.Equal(t, "Plugin", appType.String()) }) t.Run("Get application with kustomize target", func(t *testing.T) { @@ -465,7 +465,7 @@ func Test_FilterApplicationsForUpdate(t *testing.T) { SourceType: v1alpha1.ApplicationSourceTypeKustomize, }, }, - // Annotated, but invalid type + // Annotated and correct type { ObjectMeta: v1.ObjectMeta{ Name: "app2", @@ -493,9 +493,11 @@ func Test_FilterApplicationsForUpdate(t *testing.T) { } filtered, err := FilterApplicationsForUpdate(applicationList, []string{}, "") require.NoError(t, err) - require.Len(t, filtered, 1) + require.Len(t, filtered, 2) require.Contains(t, filtered, "app1") + require.Contains(t, filtered, "app2") assert.Len(t, filtered["app1"].Images, 2) + assert.Len(t, filtered["app2"].Images, 2) }) t.Run("Filter for applications with patterns", func(t *testing.T) { @@ -1048,6 +1050,271 @@ func Test_SetHelmImage(t *testing.T) { } +func Test_SetPluginImage(t *testing.T) { + t.Run("Test set Plugin image parameters on Plugin app with existing HELM_ARGS", func(t *testing.T) { + app := &v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-app", + Namespace: "testns", + Annotations: map[string]string{ + fmt.Sprintf(common.HelmParamImageNameAnnotation, "foobar"): "image.name", + fmt.Sprintf(common.HelmParamImageTagAnnotation, "foobar"): "image.tag", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + Plugin: &v1alpha1.ApplicationSourcePlugin{ + Env: []*v1alpha1.EnvEntry{ + { + Name: "HELM_ARGS", + Value: "--set image.tag=1.0.0 --set image.name=jannfis/foobar", + }, + }, + }, + }, + }, + Status: v1alpha1.ApplicationStatus{ + SourceType: v1alpha1.ApplicationSourceTypePlugin, + Summary: v1alpha1.ApplicationSummary{ + Images: []string{ + "jannfis/foobar:1.0.0", + }, + }, + }, + } + + img := image.NewFromIdentifier("foobar=jannfis/foobar:1.0.1") + + err := SetPluginImage(app, img) + require.NoError(t, err) + require.NotNil(t, app.Spec.Source.Plugin) + assert.Len(t, app.Spec.Source.Plugin.Env, 1) + + // Find correct HELM_ARGS + var helmArgs string + for _, env := range app.Spec.Source.Plugin.Env { + if env.Name == "HELM_ARGS" { + helmArgs = env.Value + break + } + } + assert.Contains(t, helmArgs, "--set image.tag=1.0.1") + }) + + t.Run("Test set Plugin image parameters on Plugin app without existing HELM_ARGS", func(t *testing.T) { + app := &v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-app", + Namespace: "testns", + Annotations: map[string]string{ + fmt.Sprintf(common.HelmParamImageNameAnnotation, "foobar"): "image.name", + fmt.Sprintf(common.HelmParamImageTagAnnotation, "foobar"): "image.tag", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + Plugin: &v1alpha1.ApplicationSourcePlugin{}, + }, + }, + Status: v1alpha1.ApplicationStatus{ + SourceType: v1alpha1.ApplicationSourceTypePlugin, + Summary: v1alpha1.ApplicationSummary{ + Images: []string{ + "jannfis/foobar:1.0.0", + }, + }, + }, + } + + img := image.NewFromIdentifier("foobar=jannfis/foobar:1.0.1") + + err := SetPluginImage(app, img) + require.NoError(t, err) + require.NotNil(t, app.Spec.Source.Plugin) + assert.Len(t, app.Spec.Source.Plugin.Env, 1) + + // Find correct HELM_ARGS + var helmArgs string + for _, env := range app.Spec.Source.Plugin.Env { + if env.Name == "HELM_ARGS" { + helmArgs = env.Value + break + } + } + assert.Contains(t, helmArgs, "--set image.tag=1.0.1") + }) + + t.Run("Test set Plugin image parameters on Plugin app with different parameters in HELM_ARGS", func(t *testing.T) { + app := &v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-app", + Namespace: "testns", + Annotations: map[string]string{ + fmt.Sprintf(common.HelmParamImageNameAnnotation, "foobar"): "foobar.image.name", + fmt.Sprintf(common.HelmParamImageTagAnnotation, "foobar"): "foobar.image.tag", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{ + Plugin: &v1alpha1.ApplicationSourcePlugin{ + Env: []*v1alpha1.EnvEntry{ + { + Name: "HELM_ARGS", + Value: "--set image.tag=1.0.0 --set image.name=jannfis/dummy", + }, + }, + }, + }, + }, + Status: v1alpha1.ApplicationStatus{ + SourceType: v1alpha1.ApplicationSourceTypePlugin, + Summary: v1alpha1.ApplicationSummary{ + Images: []string{ + "jannfis/foobar:1.0.0", + }, + }, + }, + } + + img := image.NewFromIdentifier("foobar=jannfis/foobar:1.0.1") + + err := SetPluginImage(app, img) + require.NoError(t, err) + require.NotNil(t, app.Spec.Source.Plugin) + assert.Len(t, app.Spec.Source.Plugin.Env, 1) + + // Find correct HELM_ARGS + var helmArgs string + for _, env := range app.Spec.Source.Plugin.Env { + if env.Name == "HELM_ARGS" { + helmArgs = env.Value + break + } + } + assert.Contains(t, helmArgs, "--set foobar.image.tag=1.0.1") + }) + + t.Run("Test set Plugin image parameters on non Plugin app", func(t *testing.T) { + app := &v1alpha1.Application{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-app", + Namespace: "testns", + Annotations: map[string]string{ + fmt.Sprintf(common.HelmParamImageNameAnnotation, "foobar"): "foobar.image.name", + fmt.Sprintf(common.HelmParamImageTagAnnotation, "foobar"): "foobar.image.tag", + }, + }, + Spec: v1alpha1.ApplicationSpec{ + Source: &v1alpha1.ApplicationSource{}, + }, + Status: v1alpha1.ApplicationStatus{ + SourceType: v1alpha1.ApplicationSourceTypeKustomize, + Summary: v1alpha1.ApplicationSummary{ + Images: []string{ + "jannfis/foobar:1.0.0", + }, + }, + }, + } + + img := image.NewFromIdentifier("foobar=jannfis/foobar:1.0.1") + + err := SetPluginImage(app, img) + require.Error(t, err) + }) +} + +func Test_parseHelmArgs(t *testing.T) { + t.Run("Test parse Helm arguments with mixed parameters", func(t *testing.T) { + helmArgs := "--set image.tag=1.0.0 --set image.name=jannfis/foobar -f values.yaml" + params, otherArgs := parseHelmArgs(helmArgs) + + assert.Len(t, params, 2) + assert.Equal(t, "1.0.0", params["image.tag"]) + assert.Equal(t, "jannfis/foobar", params["image.name"]) + assert.Len(t, otherArgs, 2) + assert.Contains(t, otherArgs, "-f") + assert.Contains(t, otherArgs, "values.yaml") + }) + + t.Run("Test parse Helm arguments with only --set parameters", func(t *testing.T) { + helmArgs := "--set image.tag=1.0.0 --set image.name=jannfis/foobar" + params, otherArgs := parseHelmArgs(helmArgs) + + assert.Len(t, params, 2) + assert.Equal(t, "1.0.0", params["image.tag"]) + assert.Equal(t, "jannfis/foobar", params["image.name"]) + assert.Len(t, otherArgs, 0) + }) + + t.Run("Test parse Helm arguments with only other parameters", func(t *testing.T) { + helmArgs := "-f values.yaml --debug" + params, otherArgs := parseHelmArgs(helmArgs) + + assert.Len(t, params, 0) + assert.Len(t, otherArgs, 3) + assert.Contains(t, otherArgs, "-f") + assert.Contains(t, otherArgs, "values.yaml") + assert.Contains(t, otherArgs, "--debug") + }) + + t.Run("Test parse empty Helm arguments", func(t *testing.T) { + helmArgs := "" + params, otherArgs := parseHelmArgs(helmArgs) + + assert.Len(t, params, 0) + assert.Len(t, otherArgs, 0) + }) +} + +func Test_buildHelmArgs(t *testing.T) { + t.Run("Test build Helm arguments with mixed parameters", func(t *testing.T) { + params := map[string]string{ + "image.tag": "1.0.0", + "image.name": "jannfis/foobar", + } + otherArgs := []string{"-f", "values.yaml"} + + helmArgs := buildHelmArgs(params, otherArgs) + + assert.Contains(t, helmArgs, "--set image.tag=1.0.0") + assert.Contains(t, helmArgs, "--set image.name=jannfis/foobar") + assert.Contains(t, helmArgs, "-f values.yaml") + }) + + t.Run("Test build Helm arguments with only --set parameters", func(t *testing.T) { + params := map[string]string{ + "image.tag": "1.0.0", + "image.name": "jannfis/foobar", + } + otherArgs := []string{} + + helmArgs := buildHelmArgs(params, otherArgs) + + assert.Contains(t, helmArgs, "--set image.tag=1.0.0") + assert.Contains(t, helmArgs, "--set image.name=jannfis/foobar") + }) + + t.Run("Test build Helm arguments with only other parameters", func(t *testing.T) { + params := map[string]string{} + otherArgs := []string{"-f", "values.yaml", "--debug"} + + helmArgs := buildHelmArgs(params, otherArgs) + + assert.Contains(t, helmArgs, "-f values.yaml") + assert.Contains(t, helmArgs, "--debug") + }) + + t.Run("Test build empty Helm arguments", func(t *testing.T) { + params := map[string]string{} + otherArgs := []string{} + + helmArgs := buildHelmArgs(params, otherArgs) + + assert.Equal(t, "", helmArgs) + }) +} + func TestKubernetesClient(t *testing.T) { app1 := &v1alpha1.Application{ ObjectMeta: v1.ObjectMeta{Name: "test-app1", Namespace: "testns1"}, diff --git a/pkg/argocd/update.go b/pkg/argocd/update.go index 0f1cab28..7f44255a 100644 --- a/pkg/argocd/update.go +++ b/pkg/argocd/update.go @@ -371,6 +371,8 @@ func setAppImage(app *v1alpha1.Application, img *image.ContainerImage) error { err = SetKustomizeImage(app, img) } else if appType == ApplicationTypeHelm { err = SetHelmImage(app, img) + } else if appType == ApplicationTypePlugin { + err = SetPluginImage(app, img) } else { err = fmt.Errorf("could not update application %s - neither Helm nor Kustomize application", app) } diff --git a/pkg/common/constants.go b/pkg/common/constants.go index 18cf880a..1b71f99d 100644 --- a/pkg/common/constants.go +++ b/pkg/common/constants.go @@ -56,6 +56,9 @@ const ( HelmPrefix = "helmvalues" ) +// The env var that will be used to set helm arguments for a plugin application type +const DefaultPluginEnvVarName = "HELM_ARGS" + // DefaultTargetFilePattern configurations related to the write-back functionality const DefaultTargetFilePattern = ".argocd-source-%s.yaml" const DefaultHelmValuesFilename = "values.yaml"