diff --git a/internal/controller/plugin/pluginpreset_controller.go b/internal/controller/plugin/pluginpreset_controller.go index 60c4384b3..b789613e7 100644 --- a/internal/controller/plugin/pluginpreset_controller.go +++ b/internal/controller/plugin/pluginpreset_controller.go @@ -236,7 +236,13 @@ func (r *PluginPresetReconciler) reconcilePluginPreset(ctx context.Context, pres releaseName := getReleaseName(plugin, preset) + resolvedValues, err := r.resolveExpressionsForPreset(ctx, preset, &cluster) + if err != nil { + return fmt.Errorf("failed to resolve option values for plugin %s: %w", plugin.Name, err) + } + plugin.Spec = preset.Spec.Plugin + plugin.Spec.OptionValues = resolvedValues plugin.Spec.ReleaseName = releaseName // Set the cluster name to the name of the cluster. The PluginSpec contained in the PluginPreset does not have a cluster name. plugin.Spec.ClusterName = cluster.GetName() diff --git a/internal/controller/plugin/pluginpreset_controller_test.go b/internal/controller/plugin/pluginpreset_controller_test.go index e8b236f63..e1b3672c0 100644 --- a/internal/controller/plugin/pluginpreset_controller_test.go +++ b/internal/controller/plugin/pluginpreset_controller_test.go @@ -1059,3 +1059,396 @@ func cluster(name, supportGroupTeamName string) *greenhousev1alpha1.Cluster { }, } } + +var _ = Describe("PluginPreset Expression Evaluation", Ordered, func() { + + It("should resolve a simple expression using clusterName", func() { + By("creating a cluster with metadata labels") + clusterWithMetadata := &greenhousev1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "expr-cluster-a", + Namespace: test.TestNamespace, + Labels: map[string]string{ + "cluster": "expr-cluster-a", + greenhouseapis.LabelKeyOwnedBy: testTeam.Name, + }, + }, + Spec: greenhousev1alpha1.ClusterSpec{ + AccessMode: greenhousev1alpha1.ClusterAccessModeDirect, + }, + } + Expect(test.K8sClient.Create(test.Ctx, clusterWithMetadata)).To(Succeed()) + + secretObj := clusterSecret("expr-cluster-a", testTeam.Name) + secretObj.Data = map[string][]byte{ + greenhouseapis.KubeConfigKey: clusterAKubeConfig, + } + Expect(client.IgnoreAlreadyExists(test.K8sClient.Create(test.Ctx, secretObj))).To(Succeed()) + + By("creating a PluginPreset with an expression") + expressionStr := `"app-${global.greenhouse.clusterName}.example.com"` + pluginSpec := greenhousev1alpha1.PluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginOptionValue{ + { + Name: "myRequiredOption", + Value: test.MustReturnJSONFor("myValue"), + }, + { + Name: "test.hostname", + Expression: &expressionStr, + }, + }, + } + + pluginPreset := test.NewPluginPreset("expr-simple", test.TestNamespace, + test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name), + test.WithPluginPresetPluginSpec(pluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": "expr-cluster-a", + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + By("ensuring a Plugin has been created with resolved expression value") + expPluginName := types.NamespacedName{Name: "expr-simple-expr-cluster-a", Namespace: test.TestNamespace} + expPlugin := &greenhousev1alpha1.Plugin{} + Eventually(func(g Gomega) { + err := test.K8sClient.Get(test.Ctx, expPluginName, expPlugin) + g.Expect(err).ToNot(HaveOccurred(), "Plugin should exist") + + // Verify expression was resolved to value + var hostnameFound bool + for _, ov := range expPlugin.Spec.OptionValues { + if ov.Name == "test.hostname" { + hostnameFound = true + // Expression should be nil (resolved) + g.Expect(ov.Expression).To(BeNil(), "Expression should be resolved and removed") + // Value should be set + g.Expect(ov.Value).ToNot(BeNil(), "Value should be set after expression resolution") + g.Expect(string(ov.Value.Raw)).To(Equal(`"app-expr-cluster-a.example.com"`), + "Expression should resolve to the correct hostname") + } + } + g.Expect(hostnameFound).To(BeTrue(), "test.hostname option should exist in Plugin") + }).Should(Succeed(), "the Plugin should have the resolved expression value") + + By("removing the PluginPreset") + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + test.MustDeleteCluster(test.Ctx, test.K8sClient, clusterWithMetadata) + }) + + It("should resolve expression with cluster metadata", func() { + By("creating a cluster with metadata labels") + clusterWithMetadata := &greenhousev1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "expr-cluster-meta", + Namespace: test.TestNamespace, + Labels: map[string]string{ + "cluster": "expr-cluster-meta", + greenhouseapis.LabelKeyOwnedBy: testTeam.Name, + "metadata.greenhouse.sap/region": "eu-de-1", + "metadata.greenhouse.sap/environment": "production", + }, + }, + Spec: greenhousev1alpha1.ClusterSpec{ + AccessMode: greenhousev1alpha1.ClusterAccessModeDirect, + }, + } + Expect(test.K8sClient.Create(test.Ctx, clusterWithMetadata)).To(Succeed()) + + secretObj := clusterSecret("expr-cluster-meta", testTeam.Name) + secretObj.Data = map[string][]byte{ + greenhouseapis.KubeConfigKey: clusterAKubeConfig, + } + Expect(client.IgnoreAlreadyExists(test.K8sClient.Create(test.Ctx, secretObj))).To(Succeed()) + + By("creating a PluginPreset with metadata expression") + expressionStr := `"service.${global.greenhouse.metadata.region}.example.com"` + pluginSpec := greenhousev1alpha1.PluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginOptionValue{ + { + Name: "myRequiredOption", + Value: test.MustReturnJSONFor("myValue"), + }, + { + Name: "test.serviceHost", + Expression: &expressionStr, + }, + }, + } + + pluginPreset := test.NewPluginPreset("expr-metadata", test.TestNamespace, + test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name), + test.WithPluginPresetPluginSpec(pluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": "expr-cluster-meta", + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + By("ensuring Plugin has resolved metadata expression") + expPluginName := types.NamespacedName{Name: "expr-metadata-expr-cluster-meta", Namespace: test.TestNamespace} + expPlugin := &greenhousev1alpha1.Plugin{} + Eventually(func(g Gomega) { + err := test.K8sClient.Get(test.Ctx, expPluginName, expPlugin) + g.Expect(err).ToNot(HaveOccurred(), "Plugin should exist") + + var serviceHostFound bool + for _, ov := range expPlugin.Spec.OptionValues { + if ov.Name == "test.serviceHost" { + serviceHostFound = true + g.Expect(ov.Expression).To(BeNil(), "Expression should be resolved") + g.Expect(ov.Value).ToNot(BeNil(), "Value should be set") + g.Expect(string(ov.Value.Raw)).To(Equal(`"service.eu-de-1.example.com"`), + "Expression should resolve with cluster metadata region") + } + } + g.Expect(serviceHostFound).To(BeTrue(), "test.serviceHost option should exist") + }).Should(Succeed(), "the Plugin should have the resolved metadata expression") + + By("removing the PluginPreset") + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + test.MustDeleteCluster(test.Ctx, test.K8sClient, clusterWithMetadata) + }) + + It("should keep direct values unchanged when resolving expressions", func() { + By("creating a PluginPreset with both direct values and expressions") + expressionStr := `"generated-${global.greenhouse.clusterName}"` + pluginSpec := greenhousev1alpha1.PluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginOptionValue{ + { + Name: "myRequiredOption", + Value: test.MustReturnJSONFor("myValue"), + }, + { + Name: "direct.value", + Value: test.MustReturnJSONFor("unchanged"), + }, + { + Name: "expression.value", + Expression: &expressionStr, + }, + }, + } + + pluginPreset := test.NewPluginPreset("expr-mixed", test.TestNamespace, + test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name), + test.WithPluginPresetPluginSpec(pluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": clusterA, + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + By("ensuring Plugin has both resolved and direct values") + expPluginName := types.NamespacedName{Name: "expr-mixed-" + clusterA, Namespace: test.TestNamespace} + expPlugin := &greenhousev1alpha1.Plugin{} + Eventually(func(g Gomega) { + err := test.K8sClient.Get(test.Ctx, expPluginName, expPlugin) + g.Expect(err).ToNot(HaveOccurred(), "Plugin should exist") + + // Check direct value is unchanged + g.Expect(expPlugin.Spec.OptionValues).To(ContainElement( + greenhousev1alpha1.PluginOptionValue{ + Name: "direct.value", + Value: test.MustReturnJSONFor("unchanged"), + }), "Direct value should be unchanged") + + // Check expression was resolved + var expressionResolved bool + for _, ov := range expPlugin.Spec.OptionValues { + if ov.Name == "expression.value" { + expressionResolved = true + g.Expect(ov.Expression).To(BeNil(), "Expression should be resolved") + g.Expect(ov.Value).ToNot(BeNil(), "Value should be set") + g.Expect(string(ov.Value.Raw)).To(Equal(`"generated-`+clusterA+`"`), + "Expression should be resolved correctly") + } + } + g.Expect(expressionResolved).To(BeTrue(), "expression.value should exist") + }).Should(Succeed(), "Plugin should have both resolved and direct values") + + By("removing the PluginPreset") + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + }) + + It("should not modify PluginPreset without expressions", func() { + By("creating a PluginPreset with only direct values (no expressions)") + pluginSpec := greenhousev1alpha1.PluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginOptionValue{ + { + Name: "myRequiredOption", + Value: test.MustReturnJSONFor("myValue"), + }, + { + Name: "option.direct", + Value: test.MustReturnJSONFor("directValue"), + }, + }, + } + + pluginPreset := test.NewPluginPreset("expr-no-expression", test.TestNamespace, + test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name), + test.WithPluginPresetPluginSpec(pluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": clusterA, + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + By("ensuring Plugin has the same values as PluginPreset (unchanged)") + expPluginName := types.NamespacedName{Name: "expr-no-expression-" + clusterA, Namespace: test.TestNamespace} + expPlugin := &greenhousev1alpha1.Plugin{} + Eventually(func(g Gomega) { + err := test.K8sClient.Get(test.Ctx, expPluginName, expPlugin) + g.Expect(err).ToNot(HaveOccurred(), "Plugin should exist") + + g.Expect(expPlugin.Spec.OptionValues).To(ContainElement( + greenhousev1alpha1.PluginOptionValue{ + Name: "myRequiredOption", + Value: test.MustReturnJSONFor("myValue"), + })) + + g.Expect(expPlugin.Spec.OptionValues).To(ContainElement( + greenhousev1alpha1.PluginOptionValue{ + Name: "option.direct", + Value: test.MustReturnJSONFor("directValue"), + })) + }).Should(Succeed(), "Plugin should have unchanged direct values") + + By("removing the PluginPreset") + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + }) + + It("should resolve expression with organizationName", func() { + By("creating a PluginPreset with organizationName expression") + expressionStr := `"${global.greenhouse.organizationName}-service.example.com"` + pluginSpec := greenhousev1alpha1.PluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginOptionValue{ + { + Name: "myRequiredOption", + Value: test.MustReturnJSONFor("myValue"), + }, + { + Name: "test.orgHost", + Expression: &expressionStr, + }, + }, + } + + pluginPreset := test.NewPluginPreset("expr-org", test.TestNamespace, + test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name), + test.WithPluginPresetPluginSpec(pluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": clusterA, + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + By("ensuring Plugin has resolved organizationName expression") + expPluginName := types.NamespacedName{Name: "expr-org-" + clusterA, Namespace: test.TestNamespace} + expPlugin := &greenhousev1alpha1.Plugin{} + Eventually(func(g Gomega) { + err := test.K8sClient.Get(test.Ctx, expPluginName, expPlugin) + g.Expect(err).ToNot(HaveOccurred(), "Plugin should exist") + + var orgHostFound bool + for _, ov := range expPlugin.Spec.OptionValues { + if ov.Name == "test.orgHost" { + orgHostFound = true + g.Expect(ov.Expression).To(BeNil(), "Expression should be resolved") + g.Expect(ov.Value).ToNot(BeNil(), "Value should be set") + g.Expect(string(ov.Value.Raw)).To(Equal(`"`+test.TestNamespace+`-service.example.com"`), + "Expression should resolve with organization name (namespace)") + } + } + g.Expect(orgHostFound).To(BeTrue(), "test.orgHost option should exist") + }).Should(Succeed(), "Plugin should have resolved organizationName expression") + + By("removing the PluginPreset") + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + }) + + It("should report error for invalid expression", func() { + By("creating a PluginPreset with invalid expression") + invalidExpressionStr := `"service.${global.greenhouse.nonexistent.field}.example.com"` + pluginSpec := greenhousev1alpha1.PluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginOptionValue{ + { + Name: "myRequiredOption", + Value: test.MustReturnJSONFor("myValue"), + }, + { + Name: "test.invalid", + Expression: &invalidExpressionStr, + }, + }, + } + + pluginPreset := test.NewPluginPreset("expr-invalid", test.TestNamespace, + test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name), + test.WithPluginPresetPluginSpec(pluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": clusterA, + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + By("ensuring PluginPreset reports the error in status") + Eventually(func(g Gomega) { + err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(pluginPreset), pluginPreset) + g.Expect(err).ToNot(HaveOccurred()) + + pluginFailedCondition := pluginPreset.Status.GetConditionByType(greenhousev1alpha1.PluginFailedCondition) + g.Expect(pluginFailedCondition).ToNot(BeNil(), "PluginFailedCondition should be set") + g.Expect(pluginFailedCondition.Status).To(Equal(metav1.ConditionTrue), "PluginFailedCondition should be true") + g.Expect(pluginFailedCondition.Message).To(ContainSubstring("failed to resolve"), + "Error message should indicate expression resolution failure") + }).Should(Succeed(), "PluginPreset should report error for invalid expression") + + By("removing the PluginPreset") + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + }) +}) diff --git a/internal/controller/plugin/pluginpreset_values_resolver.go b/internal/controller/plugin/pluginpreset_values_resolver.go new file mode 100644 index 000000000..d38b8d70b --- /dev/null +++ b/internal/controller/plugin/pluginpreset_values_resolver.go @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" + "github.com/cloudoperators/greenhouse/internal/helm" + "github.com/cloudoperators/greenhouse/pkg/cel" +) + +// resolveExpressionsForPreset evaluates all expression fields in PluginPreset option values +func (r *PluginPresetReconciler) resolveExpressionsForPreset( + ctx context.Context, + preset *greenhousev1alpha1.PluginPreset, + cluster *greenhousev1alpha1.Cluster, +) ([]greenhousev1alpha1.PluginOptionValue, error) { + + // Check if any expressions exist - if not, return early + hasExpressions := false + for _, ov := range preset.Spec.Plugin.OptionValues { + if ov.Expression != nil { + hasExpressions = true + break + } + } + if !hasExpressions { + return preset.Spec.Plugin.OptionValues, nil + } + + // Build greenhouse values for CEL template data + templateData, err := r.buildTemplateData(ctx, preset, cluster) + if err != nil { + return nil, fmt.Errorf("failed to build template data: %w", err) + } + + // Evaluate each option value + result := make([]greenhousev1alpha1.PluginOptionValue, 0, len(preset.Spec.Plugin.OptionValues)) + for _, optionValue := range preset.Spec.Plugin.OptionValues { + if optionValue.Expression != nil { + // Evaluate expression + evaluatedValue, err := cel.EvaluateExpression(*optionValue.Expression, templateData) + if err != nil { + return nil, fmt.Errorf("failed to evaluate expression for option %s: %w", optionValue.Name, err) + } + + // Replace expression with resolved value + result = append(result, greenhousev1alpha1.PluginOptionValue{ + Name: optionValue.Name, + Value: &apiextensionsv1.JSON{Raw: evaluatedValue}, + }) + } else { + // Keep as-is (direct value, valueFrom, etc.) + result = append(result, optionValue) + } + } + + return result, nil +} + +// buildTemplateData creates the template data map for CEL expression evaluation +func (r *PluginPresetReconciler) buildTemplateData( + ctx context.Context, + preset *greenhousev1alpha1.PluginPreset, + cluster *greenhousev1alpha1.Cluster, +) (map[string]any, error) { + + // Create temporary Plugin to reuse existing GetGreenhouseValues + tempPlugin := greenhousev1alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Name: preset.Name, + Namespace: preset.Namespace, + Labels: preset.Labels, + }, + Spec: greenhousev1alpha1.PluginSpec{ + ClusterName: cluster.Name, + }, + } + + // Get greenhouse values (clusterName, metadata, teams, etc.) + greenhouseValuesList, err := helm.GetGreenhouseValues(ctx, r.Client, tempPlugin) + if err != nil { + return nil, fmt.Errorf("failed to get greenhouse values: %w", err) + } + + // Convert flat dotted keys to nested map + // e.g., "global.greenhouse.clusterName" → map["global"]["greenhouse"]["clusterName"] + templateData := make(map[string]any) + for _, gv := range greenhouseValuesList { + if gv.Value != nil { + var value any + if err := json.Unmarshal(gv.Value.Raw, &value); err != nil { + return nil, fmt.Errorf("failed to unmarshal greenhouse value %s: %w", gv.Name, err) + } + parts := strings.Split(gv.Name, ".") + setNestedValue(templateData, parts, value) + } + } + + return templateData, nil +} + +// setNestedValue sets a value in a nested map using a slice of keys +func setNestedValue(m map[string]any, keys []string, value any) { + if len(keys) == 0 { + return + } + if len(keys) == 1 { + m[keys[0]] = value + return + } + if _, ok := m[keys[0]]; !ok { + m[keys[0]] = make(map[string]any) + } + if nested, ok := m[keys[0]].(map[string]any); ok { + setNestedValue(nested, keys[1:], value) + } +}