diff --git a/api/v1alpha1/pluginpreset_types.go b/api/v1alpha1/pluginpreset_types.go index 40eb510e5..17c4499d5 100644 --- a/api/v1alpha1/pluginpreset_types.go +++ b/api/v1alpha1/pluginpreset_types.go @@ -59,7 +59,7 @@ type PluginPresetPluginSpec struct { DisplayName string `json:"displayName,omitempty"` // Values are the values for a PluginDefinition instance. - OptionValues []PluginOptionValue `json:"optionValues,omitempty"` + OptionValues []PluginPresetPluginOptionValue `json:"optionValues,omitempty"` // ReleaseNamespace is the namespace in the remote cluster to which the backend is deployed. // Defaults to the Greenhouse managed namespace if not set. @@ -111,8 +111,8 @@ type PluginPresetPluginValueFromSource struct { // ClusterOptionOverride defines which plugin option should be override in which cluster // +Optional type ClusterOptionOverride struct { - ClusterName string `json:"clusterName"` - Overrides []PluginOptionValue `json:"overrides"` + ClusterName string `json:"clusterName"` + Overrides []PluginPresetPluginOptionValue `json:"overrides"` } const ( diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index f66b919af..6b0aefb97 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -545,7 +545,7 @@ func (in *ClusterOptionOverride) DeepCopyInto(out *ClusterOptionOverride) { *out = *in if in.Overrides != nil { in, out := &in.Overrides, &out.Overrides - *out = make([]PluginOptionValue, len(*in)) + *out = make([]PluginPresetPluginOptionValue, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -1324,7 +1324,7 @@ func (in *PluginPresetPluginSpec) DeepCopyInto(out *PluginPresetPluginSpec) { out.PluginDefinitionRef = in.PluginDefinitionRef if in.OptionValues != nil { in, out := &in.OptionValues, &out.OptionValues - *out = make([]PluginOptionValue, len(*in)) + *out = make([]PluginPresetPluginOptionValue, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } diff --git a/charts/greenhouse/ci/test-values.yaml b/charts/greenhouse/ci/test-values.yaml index c665f2b93..27fe37c41 100644 --- a/charts/greenhouse/ci/test-values.yaml +++ b/charts/greenhouse/ci/test-values.yaml @@ -15,6 +15,9 @@ global: expressionEvaluationEnabled: false integrationEnabled: false ociMirroringEnabled: false + # PluginPreset configuration for Greenhouse. + pluginPreset: + expressionEvaluationEnabled: true linkerd_enabled: false region: greenhouse registry: ghcr.io/cloudoperators/greenhouse diff --git a/charts/greenhouse/values.yaml b/charts/greenhouse/values.yaml index 1ea34bd10..3404507bd 100644 --- a/charts/greenhouse/values.yaml +++ b/charts/greenhouse/values.yaml @@ -23,6 +23,9 @@ global: expressionEvaluationEnabled: false integrationEnabled: false ociMirroringEnabled: false + # PluginPreset configuration for Greenhouse. + pluginPreset: + expressionEvaluationEnabled: false postgresqlng: enabled: true diff --git a/charts/manager/ci/test-values.yaml b/charts/manager/ci/test-values.yaml index 897d90f33..57cb691bd 100644 --- a/charts/manager/ci/test-values.yaml +++ b/charts/manager/ci/test-values.yaml @@ -13,6 +13,8 @@ global: expressionEvaluationEnabled: false integrationEnabled: false ociMirroringEnabled: false + pluginPreset: + expressionEvaluationEnabled: true controllerManager: args: diff --git a/charts/manager/crds/greenhouse.sap_pluginpresets.yaml b/charts/manager/crds/greenhouse.sap_pluginpresets.yaml index c5843ee03..6937ca114 100644 --- a/charts/manager/crds/greenhouse.sap_pluginpresets.yaml +++ b/charts/manager/crds/greenhouse.sap_pluginpresets.yaml @@ -71,14 +71,12 @@ spec: type: string overrides: items: - description: PluginOptionValue is the value for a PluginOption. + description: PluginPresetPluginOptionValue is the value for + a PluginOption. properties: expression: - description: |- - Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. - - Deprecated: Expression is deprecated on standalone Plugins and will be removed in a future release. - Consider using a PluginPreset to deploy Plugins utilizing the Expression field. + description: Expression is a YAML string with ${...} placeholders + that will be evaluated as CEL expressions. type: string name: description: Name of the values. @@ -90,11 +88,8 @@ spec: description: ValueFrom references value in another source. properties: ref: - description: |- - Ref references values defined in another resource (Plugin, PluginPreset) - - Deprecated: Ref is deprecated on standalone Plugins and will be removed in a future release. - Consider using a PluginPreset to deploy Plugins utilizing the Ref field. + description: Ref references values defined in another + resource (Plugin, PluginPreset) properties: expression: description: Expression is a CEL expression to @@ -310,14 +305,12 @@ spec: optionValues: description: Values are the values for a PluginDefinition instance. items: - description: PluginOptionValue is the value for a PluginOption. + description: PluginPresetPluginOptionValue is the value for + a PluginOption. properties: expression: - description: |- - Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. - - Deprecated: Expression is deprecated on standalone Plugins and will be removed in a future release. - Consider using a PluginPreset to deploy Plugins utilizing the Expression field. + description: Expression is a YAML string with ${...} placeholders + that will be evaluated as CEL expressions. type: string name: description: Name of the values. @@ -329,11 +322,8 @@ spec: description: ValueFrom references value in another source. properties: ref: - description: |- - Ref references values defined in another resource (Plugin, PluginPreset) - - Deprecated: Ref is deprecated on standalone Plugins and will be removed in a future release. - Consider using a PluginPreset to deploy Plugins utilizing the Ref field. + description: Ref references values defined in another + resource (Plugin, PluginPreset) properties: expression: description: Expression is a CEL expression to extract diff --git a/charts/manager/templates/_helpers.tpl b/charts/manager/templates/_helpers.tpl index de495a13b..338e96f37 100644 --- a/charts/manager/templates/_helpers.tpl +++ b/charts/manager/templates/_helpers.tpl @@ -117,3 +117,7 @@ Define postgresql helpers {{- define "plugin.ociMirroringEnabled" -}} {{- printf "%t" (required "global.plugin.ociMirroringEnabled missing" .Values.global.plugin.ociMirroringEnabled) }} {{- end }} +{{/* Render the pluginPreset expression evaluation flag */}} +{{- define "pluginPreset.expressionEvaluationEnabled" -}} + {{- printf "%t" (required "global.pluginPreset.expressionEvaluationEnabled missing" .Values.global.pluginPreset.expressionEvaluationEnabled) }} +{{- end }} \ No newline at end of file diff --git a/charts/manager/templates/manager/feature-flag.yaml b/charts/manager/templates/manager/feature-flag.yaml index 6bfb1ac94..978e05bb4 100644 --- a/charts/manager/templates/manager/feature-flag.yaml +++ b/charts/manager/templates/manager/feature-flag.yaml @@ -26,9 +26,16 @@ data: expressionEvaluationEnabled: false / true integrationEnabled: false / true ociMirroringEnabled: false / true + # enable pluginPreset features + # expressionEvaluationEnabled allows you to enable or disable CEL expression evaluation in PluginPreset + # when enabled, expressions in PluginPreset.spec.plugin.optionValues are evaluated before creating the Plugin + pluginPreset: | + expressionEvaluationEnabled: false / true dex: | storage: {{ include "dex.backend" $ }} plugin: | expressionEvaluationEnabled: {{ include "plugin.expressionEvaluationEnabled" $ }} integrationEnabled: {{ include "plugin.integrationEnabled" $ }} ociMirroringEnabled: {{ include "plugin.ociMirroringEnabled" $ }} + pluginPreset: | + expressionEvaluationEnabled: {{ include "pluginPreset.expressionEvaluationEnabled" $ }} \ No newline at end of file diff --git a/cmd/greenhouse/controllers.go b/cmd/greenhouse/controllers.go index 98992f02b..0ed5b6725 100644 --- a/cmd/greenhouse/controllers.go +++ b/cmd/greenhouse/controllers.go @@ -35,7 +35,7 @@ var knownControllers = map[string]func(controllerName string, mgr ctrl.Manager) // Plugin controllers. "plugin": startPluginReconciler, - "pluginPreset": (&plugincontrollers.PluginPresetReconciler{}).SetupWithManager, + "pluginPreset": startPluginPresetReconciler, "catalog": startCatalogReconciler, "pluginDefinition": startPluginDefinitionReconciler, @@ -93,6 +93,12 @@ func startPluginReconciler(name string, mgr ctrl.Manager) error { }).SetupWithManager(name, mgr) } +func startPluginPresetReconciler(name string, mgr ctrl.Manager) error { + return (&plugincontrollers.PluginPresetReconciler{ + ExpressionEvaluationEnabled: featureFlags.IsPresetExpressionEvaluationEnabled(), + }).SetupWithManager(name, mgr) +} + func startPluginDefinitionReconciler(name string, mgr ctrl.Manager) error { return (&plugindefinitioncontroller.PluginDefinitionReconciler{ OCIMirroringEnabled: featureFlags.IsOCIMirroringEnabled(), diff --git a/dev-env/dev.values.yaml b/dev-env/dev.values.yaml index e0a190fac..d32d1770a 100644 --- a/dev-env/dev.values.yaml +++ b/dev-env/dev.values.yaml @@ -9,6 +9,8 @@ global: expressionEvaluationEnabled: true integrationEnabled: true ociMirroringEnabled: true + pluginPreset: + expressionEvaluationEnabled: true alerts: enabled: false certManager: diff --git a/docs/reference/api/index.html b/docs/reference/api/index.html index 9ee57655c..778851cf6 100644 --- a/docs/reference/api/index.html +++ b/docs/reference/api/index.html @@ -1089,8 +1089,8 @@

ClusterOptionOverride overrides
- -[]PluginOptionValue + +[]PluginPresetPluginOptionValue @@ -3073,8 +3073,6 @@

PluginOptionValue

(Appears on: -ClusterOptionOverride, -PluginPresetPluginSpec, PluginSpec)

PluginOptionValue is the value for a PluginOption.

@@ -3267,6 +3265,11 @@

PluginPreset

PluginPresetPluginOptionValue

+

+(Appears on: +ClusterOptionOverride, +PluginPresetPluginSpec) +

PluginPresetPluginOptionValue is the value for a PluginOption.

@@ -3377,8 +3380,8 @@

PluginPresetPluginSpec optionValues
- -[]PluginOptionValue + +[]PluginPresetPluginOptionValue diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml index 4ceb4992a..82e5ab6a0 100755 --- a/docs/reference/api/openapi.yaml +++ b/docs/reference/api/openapi.yaml @@ -1105,14 +1105,10 @@ components: type: string overrides: items: - description: PluginOptionValue is the value for a PluginOption. + description: PluginPresetPluginOptionValue is the value for a PluginOption. properties: expression: - description: |- - Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. - - Deprecated: Expression is deprecated on standalone Plugins and will be removed in a future release. - Consider using a PluginPreset to deploy Plugins utilizing the Expression field. + description: Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. type: string name: description: Name of the values. @@ -1124,11 +1120,7 @@ components: description: ValueFrom references value in another source. properties: ref: - description: |- - Ref references values defined in another resource (Plugin, PluginPreset) - - Deprecated: Ref is deprecated on standalone Plugins and will be removed in a future release. - Consider using a PluginPreset to deploy Plugins utilizing the Ref field. + description: Ref references values defined in another resource (Plugin, PluginPreset) properties: expression: description: Expression is a CEL expression to extract the value from the referenced resource @@ -1327,14 +1319,10 @@ components: optionValues: description: Values are the values for a PluginDefinition instance. items: - description: PluginOptionValue is the value for a PluginOption. + description: PluginPresetPluginOptionValue is the value for a PluginOption. properties: expression: - description: |- - Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. - - Deprecated: Expression is deprecated on standalone Plugins and will be removed in a future release. - Consider using a PluginPreset to deploy Plugins utilizing the Expression field. + description: Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. type: string name: description: Name of the values. @@ -1346,11 +1334,7 @@ components: description: ValueFrom references value in another source. properties: ref: - description: |- - Ref references values defined in another resource (Plugin, PluginPreset) - - Deprecated: Ref is deprecated on standalone Plugins and will be removed in a future release. - Consider using a PluginPreset to deploy Plugins utilizing the Ref field. + description: Ref references values defined in another resource (Plugin, PluginPreset) properties: expression: description: Expression is a CEL expression to extract the value from the referenced resource diff --git a/docs/reference/components/pluginpreset.md b/docs/reference/components/pluginpreset.md index 95737d8a9..11054b4c5 100644 --- a/docs/reference/components/pluginpreset.md +++ b/docs/reference/components/pluginpreset.md @@ -33,6 +33,9 @@ spec: optionValues: - name: perses.sidecar.enabled value: true + - name: perses.ingress.host + expression: | + "perses.${global.greenhouse.clusterName}.example.com" pluginDefinitionRef: kind: ClusterPluginDefinition name: perses @@ -86,6 +89,113 @@ spec: `.spec.deletionPolicy` is an optional field that specifies the behaviour when a PluginPreset is deleted. The possible values are `Delete` and `Retain`. If set to `Delete` (the default), all Plugins created by the PluginPreset will also be deleted when the PluginPreset is deleted. If set to `Retain`, the Plugins will remain after the PluginPreset is deleted or if the Cluster stops matching the selector. +## CEL Expressions in OptionValues + +PluginPresets support CEL (Common Expression Language) expressions in `optionValues`. +When `pluginPreset.expressionEvaluationEnabled` is enabled, expressions are evaluated during PluginPreset reconciliation and the resulting Plugin contains only the resolved values +with no expression fields remaining. + +Expressions use the `${...}` syntax to reference dynamic values: + +```yaml +spec: + plugin: + optionValues: + - name: app.hostname + expression: | + "myapp.${global.greenhouse.clusterName}.example.com" +``` + +When this PluginPreset creates a Plugin for a cluster named `cluster-a`, the Plugin will contain: + +```yaml +spec: + optionValues: + - name: app.hostname + value: "myapp.cluster-a.example.com" +``` + +### Available Variables + +| Variable | Description | Example Value | +|--------------------------------------|----------------------------|------------------------------| +| `global.greenhouse.clusterName` | Name of the target cluster | `cluster-a` | +| `global.greenhouse.organizationName` | Organization namespace | `my-org` | +| `global.greenhouse.clusterNames` | List of all cluster names | `["cluster-a", "cluster-b"]` | +| `global.greenhouse.teamNames` | List of all team names | `["team-1", "team-2"]` | +| `global.greenhouse.baseDomain` | Base DNS domain | `greenhouse.example.com` | +| `global.greenhouse.metadata.*` | Cluster metadata labels | `eu-de-1` | + +> :information_source: `global.greenhouse.metadata.*` values are derived from cluster labels prefixed with `metadata.greenhouse.sap/`. For example, the label `metadata.greenhouse.sap/region: eu-de-1` becomes available as `global.greenhouse.metadata.region`. + +### Examples + +**Hostname per cluster:** + +```yaml +- name: ingress.host + expression: | + "service.${global.greenhouse.clusterName}.example.com" +# Result for cluster "cluster-a": "service.cluster-a.example.com" +``` + +**Using cluster metadata:** + +```yaml +- name: ingress.host + expression: | + "service.${global.greenhouse.metadata.region}.example.com" +# Result: "service.eu-de-1.example.com" +# Requires label metadata.greenhouse.sap/region on the cluster +``` + +**Combining variables:** + +```yaml +- name: app.fqdn + expression: | + "${global.greenhouse.clusterName}-${global.greenhouse.organizationName}" +# Result for cluster "cluster-a" in org "my-org": "cluster-a-my-org" +``` + +### Expressions in ClusterOptionOverrides + +Expressions can also be used in `clusterOptionOverrides`. Overrides are merged before expression evaluation, so override expressions are also resolved: + +```yaml +spec: + plugin: + optionValues: + - name: app.mode + value: "standard" + clusterOptionOverrides: + - clusterName: special-cluster + overrides: + - name: app.hostname + expression: | + "special.${global.greenhouse.metadata.region}.example.com" +``` + +> :information_source: Expressions are evaluated in PluginPresets when `pluginPreset.expressionEvaluationEnabled` is enabled. +Standalone Plugin expressions are still supported (deprecated) and may be evaluated by the Plugin controller depending on feature flags. + + +## Feature Flag + +CEL expression evaluation is disabled by default. To enable it, set `pluginPreset.expressionEvaluationEnabled: true` in the Greenhouse feature flags ConfigMap. + +```yaml +# greenhouse-feature-flags ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: greenhouse-feature-flags + namespace: greenhouse +data: + pluginPreset: | + expressionEvaluationEnabled: true +``` + ## Next Steps - [Managing Plugins for multiple clusters](./../../../user-guides/plugin/plugin-management) diff --git a/e2e/pluginpreset/e2e_test.go b/e2e/pluginpreset/e2e_test.go new file mode 100644 index 000000000..d4cc6d76f --- /dev/null +++ b/e2e/pluginpreset/e2e_test.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build pluginpresetE2E + +package pluginpreset + +import ( + "context" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + greenhouseapis "github.com/cloudoperators/greenhouse/api" + greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" + "github.com/cloudoperators/greenhouse/e2e/pluginpreset/scenarios" + "github.com/cloudoperators/greenhouse/e2e/shared" + "github.com/cloudoperators/greenhouse/internal/clientutil" + "github.com/cloudoperators/greenhouse/internal/test" +) + +const ( + remoteClusterName = "remote-pluginpreset-cluster" +) + +var ( + env *shared.TestEnv + ctx context.Context + adminClient client.Client + remoteClient client.Client + testStartTime time.Time + team *greenhousev1alpha1.Team +) + +func TestE2e(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "PluginPreset E2E Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + ctx = context.Background() + env = shared.NewExecutionEnv() + + var err error + adminClient, err = clientutil.NewK8sClientFromRestClientGetter(env.AdminRestClientGetter) + Expect(err).ToNot(HaveOccurred(), "there should be no error creating the admin client") + + remoteClient, err = clientutil.NewK8sClientFromRestClientGetter(env.RemoteRestClientGetter) + Expect(err).ToNot(HaveOccurred(), "there should be no error creating the remote client") + + env = env.WithOrganization(ctx, adminClient, "./testdata/organization.yaml") + + team = test.NewTeam(ctx, "test-pluginpreset-e2e-team", env.TestNamespace, test.WithTeamLabel(greenhouseapis.LabelKeySupportGroup, "true")) + err = adminClient.Create(ctx, team) + Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred(), "there should be no error creating a Team") + + testStartTime = time.Now().UTC() +}) + +var _ = AfterSuite(func() { + shared.OffBoardRemoteCluster(ctx, adminClient, remoteClient, testStartTime, remoteClusterName, env.TestNamespace) + test.EventuallyDeleted(ctx, adminClient, team) + env.GenerateGreenhouseControllerLogs(ctx, testStartTime) + env.GenerateFluxControllerLogs(ctx, "helm-controller", testStartTime) +}) + +var _ = Describe("PluginPreset E2E", Ordered, func() { + + It("should onboard remote cluster", func() { + By("onboarding remote cluster") + shared.OnboardRemoteCluster(ctx, adminClient, env.RemoteKubeConfigBytes, remoteClusterName, env.TestNamespace, team.Name) + }) + + It("should have a cluster resource created and ready", func() { + By("verifying if the cluster resource is created") + Eventually(func(g Gomega) { + err := adminClient.Get(ctx, client.ObjectKey{Name: remoteClusterName, Namespace: env.TestNamespace}, &greenhousev1alpha1.Cluster{}) + g.Expect(err).ToNot(HaveOccurred()) + }).Should(Succeed(), "cluster resource should be created") + + By("verifying the cluster status is ready") + shared.ClusterIsReady(ctx, adminClient, remoteClusterName, env.TestNamespace) + }) + + It("should resolve CEL expressions in PluginPreset", func() { + scenarios.PluginPresetExpressionEvaluation(ctx, adminClient, remoteClient, env, remoteClusterName, team.Name) + }) +}) diff --git a/e2e/pluginpreset/scenarios/constants.go b/e2e/pluginpreset/scenarios/constants.go new file mode 100644 index 000000000..a4416721f --- /dev/null +++ b/e2e/pluginpreset/scenarios/constants.go @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +const ( + optionUIMessage = "ui.message" + optionUIBackend = "ui.backend" + optionReplicaCount = "replicaCount" +) diff --git a/e2e/pluginpreset/scenarios/expression_evaluation.go b/e2e/pluginpreset/scenarios/expression_evaluation.go new file mode 100644 index 000000000..2abbb8a35 --- /dev/null +++ b/e2e/pluginpreset/scenarios/expression_evaluation.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhouseapis "github.com/cloudoperators/greenhouse/api" + greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" + "github.com/cloudoperators/greenhouse/e2e/plugin/fixtures" + "github.com/cloudoperators/greenhouse/e2e/shared" + "github.com/cloudoperators/greenhouse/internal/test" +) + +func PluginPresetExpressionEvaluation(ctx context.Context, adminClient, remoteClient client.Client, env *shared.TestEnv, remoteClusterName, teamName string) { + By("creating plugin definition") + testPluginDefinition := fixtures.PreparePodInfoClusterPluginDefinition(env.TestNamespace, "6.9.0") + err := adminClient.Create(ctx, testPluginDefinition) + Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred()) + + By("checking the test plugin definition is ready") + Eventually(func(g Gomega) { + err = adminClient.Get(ctx, client.ObjectKeyFromObject(testPluginDefinition), testPluginDefinition) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(testPluginDefinition.Status.IsReadyTrue()).To(BeTrue()) + }).Should(Succeed()) + + By("adding labels to remote cluster") + remoteCluster := &greenhousev1alpha1.Cluster{} + err = adminClient.Get(ctx, client.ObjectKey{Name: remoteClusterName, Namespace: env.TestNamespace}, remoteCluster) + Expect(err).ToNot(HaveOccurred()) + if remoteCluster.Labels == nil { + remoteCluster.Labels = make(map[string]string) + } + remoteCluster.Labels["app"] = "test-expr-cluster" + err = adminClient.Update(ctx, remoteCluster) + Expect(err).ToNot(HaveOccurred()) + + By("creating PluginPreset with CEL expressions") + expressionHostname := `"podinfo-${global.greenhouse.clusterName}.example.com"` + expressionOrg := `"${global.greenhouse.organizationName}-service"` + + presetPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: testPluginDefinition.Name, + }, + ReleaseName: "expr-test", + ReleaseNamespace: env.TestNamespace, + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + { + Name: optionReplicaCount, + Value: test.MustReturnJSONFor("1"), + }, + { + Name: optionUIMessage, + Expression: &expressionHostname, + }, + { + Name: optionUIBackend, + Expression: &expressionOrg, + }, + }, + } + + testPluginPreset := test.NewPluginPreset("expr-eval-preset", env.TestNamespace, + test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, teamName), + test.WithPresetPluginSpec(presetPluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test-expr-cluster"}, + }), + ) + err = adminClient.Create(ctx, testPluginPreset) + Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred()) + + By("checking Plugin is created with resolved expression values") + expectedPluginName := testPluginPreset.Name + "-" + remoteClusterName + Eventually(func(g Gomega) { + pluginList := &greenhousev1alpha1.PluginList{} + err = adminClient.List(ctx, pluginList, client.MatchingLabels{greenhouseapis.LabelKeyPluginPreset: testPluginPreset.Name}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(pluginList.Items).To(HaveLen(1)) + + plugin := &pluginList.Items[0] + g.Expect(plugin.Name).To(Equal(expectedPluginName)) + + // Verify no expression fields remain + for _, ov := range plugin.Spec.OptionValues { + g.Expect(ov.Expression).To(BeNil(), "Plugin should not contain expression fields - option: "+ov.Name) + } + + // Verify hostname resolved + var hostnameFound bool + for _, ov := range plugin.Spec.OptionValues { + if ov.Name == optionUIMessage { + hostnameFound = true + g.Expect(ov.Value).ToNot(BeNil()) + g.Expect(string(ov.Value.Raw)).To(Equal(`"podinfo-` + remoteClusterName + `.example.com"`)) + } + } + g.Expect(hostnameFound).To(BeTrue()) + + // Verify org expression resolved + var orgFound bool + for _, ov := range plugin.Spec.OptionValues { + if ov.Name == optionUIBackend { + orgFound = true + g.Expect(ov.Value).ToNot(BeNil()) + g.Expect(string(ov.Value.Raw)).To(Equal(`"` + env.TestNamespace + `-service"`)) + } + } + g.Expect(orgFound).To(BeTrue()) + }).Should(Succeed(), "Plugin should be created with resolved expression values") + + By("cleaning up") + test.EventuallyDeleted(ctx, adminClient, testPluginPreset) + test.EventuallyDeleted(ctx, adminClient, testPluginDefinition) +} diff --git a/e2e/pluginpreset/testdata/organization.yaml b/e2e/pluginpreset/testdata/organization.yaml new file mode 100644 index 000000000..b1477f697 --- /dev/null +++ b/e2e/pluginpreset/testdata/organization.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +# Cluster Scoped Resource +apiVersion: greenhouse.sap/v1alpha1 +kind: Organization +metadata: + name: greenhouse +spec: + description: greenhouse organization + displayName: Greenhouse + mappedOrgAdminIdPGroup: GREENHOUSE_ORG_ADMIN \ No newline at end of file diff --git a/internal/cmd/plugin_template.go b/internal/cmd/plugin_template.go index e12773a2d..abddddeee 100644 --- a/internal/cmd/plugin_template.go +++ b/internal/cmd/plugin_template.go @@ -18,6 +18,7 @@ import ( "k8s.io/apimachinery/pkg/util/json" helminternal "github.com/cloudoperators/greenhouse/internal/helm" + "github.com/cloudoperators/greenhouse/internal/util" greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" ) @@ -190,7 +191,7 @@ func (o *PluginTemplatePresetOptions) prepareValues() error { ) // Merge PluginPreset values. - values = helminternal.MergePluginOptionValues(values, o.pluginPreset.Spec.Plugin.OptionValues) + values = helminternal.MergePluginOptionValues(values, util.ConvertToPluginOptionValues(o.pluginPreset.Spec.Plugin.OptionValues)) // Merge cluster overrides. values = helminternal.MergePluginOptionValues(values, o.getClusterSpecificOverrides()) @@ -289,7 +290,7 @@ func createPluginOptionValue(name, value string) (*greenhousev1alpha1.PluginOpti func (o *PluginTemplatePresetOptions) getClusterSpecificOverrides() []greenhousev1alpha1.PluginOptionValue { for _, override := range o.pluginPreset.Spec.ClusterOptionOverrides { if override.ClusterName == o.clusterName { - return override.Overrides + return util.ConvertToPluginOptionValues(override.Overrides) } } return []greenhousev1alpha1.PluginOptionValue{} diff --git a/internal/cmd/plugin_template_test.go b/internal/cmd/plugin_template_test.go index 5ae43d9c0..61315e286 100644 --- a/internal/cmd/plugin_template_test.go +++ b/internal/cmd/plugin_template_test.go @@ -191,7 +191,7 @@ var _ = Describe("prepareValues", func() { Context("with PluginPreset overrides", func() { BeforeEach(func() { - pluginPreset.Spec.Plugin.OptionValues = []greenhousev1alpha1.PluginOptionValue{ + pluginPreset.Spec.Plugin.OptionValues = []greenhousev1alpha1.PluginPresetPluginOptionValue{ { Name: "replicas", Value: &apiextensionsv1.JSON{Raw: []byte("3")}, @@ -221,7 +221,7 @@ var _ = Describe("prepareValues", func() { pluginPreset.Spec.ClusterOptionOverrides = []greenhousev1alpha1.ClusterOptionOverride{ { ClusterName: "test-cluster", - Overrides: []greenhousev1alpha1.PluginOptionValue{ + Overrides: []greenhousev1alpha1.PluginPresetPluginOptionValue{ { Name: "replicas", Value: &apiextensionsv1.JSON{Raw: []byte("5")}, diff --git a/internal/controller/plugin/pluginpreset_controller.go b/internal/controller/plugin/pluginpreset_controller.go index b48e99b82..9aea8938f 100644 --- a/internal/controller/plugin/pluginpreset_controller.go +++ b/internal/controller/plugin/pluginpreset_controller.go @@ -46,7 +46,8 @@ var presetExposedConditions = []greenhousemetav1alpha1.ConditionType{ // PluginPresetReconciler reconciles a PluginPreset object type PluginPresetReconciler struct { client.Client - recorder events.EventRecorder + recorder events.EventRecorder + ExpressionEvaluationEnabled bool } //+kubebuilder:rbac:groups=greenhouse.sap,resources=pluginpresets,verbs=get;list;watch;update @@ -56,6 +57,7 @@ type PluginPresetReconciler struct { //+kubebuilder:rbac:groups=greenhouse.sap,resources=clusters,verbs=get;list;watch; //+kubebuilder:rbac:groups=greenhouse.sap,resources=plugindefinitions,verbs=get;list;watch; //+kubebuilder:rbac:groups=greenhouse.sap,resources=clusterplugindefinitions,verbs=get;list;watch; +//+kubebuilder:rbac:groups=greenhouse.sap,resources=teams,verbs=get;list;watch // SetupWithManager sets up the controller with the Manager. func (r *PluginPresetReconciler) SetupWithManager(name string, mgr ctrl.Manager) error { @@ -241,12 +243,19 @@ func (r *PluginPresetReconciler) reconcilePluginPreset(ctx context.Context, pres releaseName := getReleaseName(plugin, preset) + // Apply overrides first, then resolve expressions + presetWithOverrides := applyOverridesToPreset(preset, cluster.GetName()) + + resolvedValues, err := r.resolvePluginOptionValuesForPreset(ctx, presetWithOverrides, &cluster) + if err != nil { + return fmt.Errorf("failed to resolve option values for plugin %s: %w", plugin.Name, err) + } + plugin.Spec = pluginSpecFromPluginPreset(preset, cluster.GetName()) + plugin.Spec.OptionValues = resolvedValues plugin.Spec.ReleaseName = releaseName // transport plugin preset labels to plugin plugin = (lifecycle.NewPropagator(preset, plugin).Apply()).(*greenhousev1alpha1.Plugin) - // overrides options based on preset definition - overridesPluginOptionValues(plugin, preset) return nil }) if err != nil { @@ -354,30 +363,6 @@ func isPluginManagedByPreset(plugin *greenhousev1alpha1.Plugin, presetName strin return plugin.Labels[greenhouseapis.LabelKeyPluginPreset] == presetName } -func overridesPluginOptionValues(plugin *greenhousev1alpha1.Plugin, preset *greenhousev1alpha1.PluginPreset) { - index := slices.IndexFunc(preset.Spec.ClusterOptionOverrides, func(override greenhousev1alpha1.ClusterOptionOverride) bool { - return override.ClusterName == plugin.Spec.ClusterName - }) - - // when plugin is running on different cluster then defined in - if index == -1 { - return - } - - // overrides value - for _, overrideValue := range preset.Spec.ClusterOptionOverrides[index].Overrides { - valueIndex := slices.IndexFunc(plugin.Spec.OptionValues, func(value greenhousev1alpha1.PluginOptionValue) bool { - return value.Name == overrideValue.Name - }) - - if valueIndex == -1 { - plugin.Spec.OptionValues = append(plugin.Spec.OptionValues, overrideValue) - } else { - plugin.Spec.OptionValues[valueIndex] = overrideValue - } - } -} - // generatePluginName generates a name for a plugin based on the used PluginPreset's name and the Cluster. func generatePluginName(p *greenhousev1alpha1.PluginPreset, cluster *greenhousev1alpha1.Cluster) string { return buildPluginName(p.Name, cluster.GetName()) @@ -542,7 +527,7 @@ func pluginSpecFromPluginPreset(preset *greenhousev1alpha1.PluginPreset, cluster return greenhousev1alpha1.PluginSpec{ PluginDefinitionRef: preset.Spec.Plugin.PluginDefinitionRef, DisplayName: preset.Spec.Plugin.DisplayName, - OptionValues: preset.Spec.Plugin.OptionValues, + OptionValues: util.ConvertToPluginOptionValues(preset.Spec.Plugin.OptionValues), ReleaseNamespace: preset.Spec.Plugin.ReleaseNamespace, DeletionPolicy: preset.Spec.Plugin.DeletionPolicy, IgnoreDifferences: preset.Spec.Plugin.IgnoreDifferences, diff --git a/internal/controller/plugin/pluginpreset_controller_test.go b/internal/controller/plugin/pluginpreset_controller_test.go index ba03ea664..3e5cd1ea8 100644 --- a/internal/controller/plugin/pluginpreset_controller_test.go +++ b/internal/controller/plugin/pluginpreset_controller_test.go @@ -264,7 +264,11 @@ var _ = Describe("PluginPreset Controller Lifecycle", Ordered, func() { }).Should(Succeed(), "the Plugin should be created") By("checking plugin options with plugin definition defaults and plugin preset values") - Expect(expPlugin.Spec.OptionValues).To(ContainElement(pluginPreset.Spec.Plugin.OptionValues[0])) + Expect(expPlugin.Spec.OptionValues).To(ContainElement(greenhousev1alpha1.PluginOptionValue{ + Name: pluginPreset.Spec.Plugin.OptionValues[0].Name, + Value: pluginPreset.Spec.Plugin.OptionValues[0].Value, + })) + Expect(expPlugin.Spec.OptionValues).To(ContainElement(greenhousev1alpha1.PluginOptionValue{ Name: defaultPluginDefinition.Spec.Options[0].Name, Value: defaultPluginDefinition.Spec.Options[0].Default, @@ -536,7 +540,7 @@ var _ = Describe("PluginPreset Controller Lifecycle", Ordered, func() { }, }, ), - test.WithClusterOverride(clusterA, []greenhousev1alpha1.PluginOptionValue{ + test.WithClusterOverride(clusterA, []greenhousev1alpha1.PluginPresetPluginOptionValue{ {Name: "test-required-option-1", Value: test.MustReturnJSONFor(5)}, }), ) @@ -553,7 +557,10 @@ var _ = Describe("PluginPreset Controller Lifecycle", Ordered, func() { Eventually(func(g Gomega) { plugin = verifyPluginCreatedWithHelmRelease(g, pluginObjectKey) }).Should(Succeed(), "the Plugin should be created successfully with HelmRelease") - Expect(plugin.Spec.OptionValues).To(ContainElement(pluginPreset.Spec.ClusterOptionOverrides[0].Overrides[0]), + Expect(plugin.Spec.OptionValues).To(ContainElement(greenhousev1alpha1.PluginOptionValue{ + Name: pluginPreset.Spec.ClusterOptionOverrides[0].Overrides[0].Name, + Value: pluginPreset.Spec.ClusterOptionOverrides[0].Overrides[0].Value, + }), "ClusterOptionOverrides should be applied to the Plugin OptionValues") By("removing plugin preset") @@ -907,152 +914,513 @@ var _ = Describe("PluginPreset Controller Lifecycle", Ordered, func() { By("removing plugin preset") test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) }) + + It("should resolve a simple expression using clusterName", func() { + By("creating a PluginPreset with an expression") + expressionStr := `"app-${global.greenhouse.clusterName}.example.com"` + presetPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + { + 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.WithPresetPluginSpec(presetPluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": clusterA, + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + By("ensuring Plugin has resolved expression value") + expPluginName := types.NamespacedName{Name: "expr-simple-" + 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 hostnameFound bool + for _, ov := range expPlugin.Spec.OptionValues { + if ov.Name == "test.hostname" { + hostnameFound = true + g.Expect(ov.Value).ToNot(BeNil(), "Value should be set") + g.Expect(string(ov.Value.Raw)).To(Equal(`"app-`+clusterA+`.example.com"`), + "Expression should resolve with cluster name") + } + } + g.Expect(hostnameFound).To(BeTrue(), "test.hostname should exist in Plugin") + }).Should(Succeed()) + + By("removing the PluginPreset") + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + }) + + It("should resolve expression with cluster metadata", func() { + By("adding metadata labels to clusterA") + clusterAObj := &greenhousev1alpha1.Cluster{} + Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{ + Name: clusterA, Namespace: test.TestNamespace, + }, clusterAObj)).To(Succeed()) + + _, err := clientutil.CreateOrPatch(test.Ctx, test.K8sClient, clusterAObj, func() error { + clusterAObj.Labels["metadata.greenhouse.sap/region"] = "eu-de-1" + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + By("creating a PluginPreset with metadata expression") + expressionStr := `"service.${global.greenhouse.metadata.region}.example.com"` + presetPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + { + 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.WithPresetPluginSpec(presetPluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": clusterA, + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + By("ensuring Plugin has resolved metadata expression") + expPluginName := types.NamespacedName{Name: "expr-metadata-" + clusterA, Namespace: test.TestNamespace} + expPlugin := &greenhousev1alpha1.Plugin{} + Eventually(func(g Gomega) { + err := test.K8sClient.Get(test.Ctx, expPluginName, expPlugin) + g.Expect(err).ToNot(HaveOccurred()) + + var found bool + for _, ov := range expPlugin.Spec.OptionValues { + if ov.Name == "test.serviceHost" { + found = true + g.Expect(ov.Expression).To(BeNil()) + g.Expect(ov.Value).ToNot(BeNil()) + g.Expect(string(ov.Value.Raw)).To(Equal(`"service.eu-de-1.example.com"`)) + } + } + g.Expect(found).To(BeTrue()) + }).Should(Succeed()) + + By("cleaning up metadata label") + Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{ + Name: clusterA, Namespace: test.TestNamespace, + }, clusterAObj)).To(Succeed()) + _, err = clientutil.CreateOrPatch(test.Ctx, test.K8sClient, clusterAObj, func() error { + delete(clusterAObj.Labels, "metadata.greenhouse.sap/region") + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + }) + + It("should keep direct values unchanged when resolving expressions", func() { + expressionStr := `"generated-${global.greenhouse.clusterName}"` + presetPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + { + 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.WithPresetPluginSpec(presetPluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": clusterA, + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + 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()) + + g.Expect(expPlugin.Spec.OptionValues).To(ContainElement( + greenhousev1alpha1.PluginOptionValue{ + Name: "direct.value", + Value: test.MustReturnJSONFor("unchanged"), + }), "Direct value should be unchanged") + + var exprResolved bool + for _, ov := range expPlugin.Spec.OptionValues { + if ov.Name == "expression.value" { + exprResolved = true + g.Expect(ov.Expression).To(BeNil()) + g.Expect(ov.Value).ToNot(BeNil()) + g.Expect(string(ov.Value.Raw)).To(Equal(`"generated-` + clusterA + `"`)) + } + } + g.Expect(exprResolved).To(BeTrue()) + }).Should(Succeed()) + + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + }) + + It("should report error for invalid expression", func() { + invalidExpressionStr := `"service.${global.greenhouse.nonexistent.field}.example.com"` + presetPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{ + PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{ + Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, + Name: pluginPresetDefinitionName, + }, + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + { + 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.WithPresetPluginSpec(presetPluginSpec), + test.WithPluginPresetClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "cluster": clusterA, + }, + })) + Expect(test.K8sClient.Create(test.Ctx, pluginPreset)).To(Succeed()) + + 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()) + g.Expect(pluginFailedCondition.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(pluginFailedCondition.Message).To(ContainSubstring("failed to resolve")) + }).Should(Succeed()) + + test.EventuallyDeleted(test.Ctx, test.K8sClient, pluginPreset) + }) + + It("should return error when expression is set but ExpressionEvaluationEnabled is false", func() { + reconciler := &PluginPresetReconciler{ + Client: test.K8sClient, + ExpressionEvaluationEnabled: false, + } + + expressionStr := `"app-${global.greenhouse.clusterName}.example.com"` + preset := &greenhousev1alpha1.PluginPreset{ + ObjectMeta: metav1.ObjectMeta{ + Name: "flag-off-test", + Namespace: test.TestNamespace, + }, + Spec: greenhousev1alpha1.PluginPresetSpec{ + Plugin: greenhousev1alpha1.PluginPresetPluginSpec{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + { + Name: "direct.value", + Value: test.MustReturnJSONFor("works"), + }, + { + Name: "test.hostname", + Expression: &expressionStr, + }, + }, + }, + }, + } + + cluster := &greenhousev1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: test.TestNamespace, + }, + } + + _, err := reconciler.resolvePluginOptionValuesForPreset(test.Ctx, preset, cluster) + Expect(err).To(HaveOccurred(), "should return error when expression exists but flag is disabled") + Expect(err.Error()).To(ContainSubstring("expressionEvaluationEnabled"), + "error should mention the flag") + Expect(err.Error()).To(ContainSubstring("test.hostname"), + "error should mention the option name") + }) + + It("should succeed when no expressions and flag is disabled", func() { + reconciler := &PluginPresetReconciler{ + Client: test.K8sClient, + ExpressionEvaluationEnabled: false, + } + + preset := &greenhousev1alpha1.PluginPreset{ + ObjectMeta: metav1.ObjectMeta{ + Name: "flag-off-no-expr", + Namespace: test.TestNamespace, + }, + Spec: greenhousev1alpha1.PluginPresetSpec{ + Plugin: greenhousev1alpha1.PluginPresetPluginSpec{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + { + Name: "direct.value", + Value: test.MustReturnJSONFor("works"), + }, + { + Name: "another.value", + Value: test.MustReturnJSONFor(42), + }, + }, + }, + }, + } + + cluster := &greenhousev1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: test.TestNamespace, + }, + } + + result, err := reconciler.resolvePluginOptionValuesForPreset(test.Ctx, preset, cluster) + Expect(err).ToNot(HaveOccurred(), "should succeed without expressions") + Expect(result).To(HaveLen(2)) + Expect(result[0].Name).To(Equal("direct.value")) + Expect(result[0].Value).To(Equal(test.MustReturnJSONFor("works"))) + Expect(result[1].Name).To(Equal("another.value")) + Expect(result[1].Value).To(Equal(test.MustReturnJSONFor(42))) + }) + }) -var _ = Describe("overridesPluginOptionValues", Ordered, func() { - DescribeTable("test cases", func(plugin *greenhousev1alpha1.Plugin, preset *greenhousev1alpha1.PluginPreset, expectedPlugin *greenhousev1alpha1.Plugin) { - overridesPluginOptionValues(plugin, preset) - Expect(plugin).To(BeEquivalentTo(expectedPlugin)) - }, - Entry("with no defined pluginPresetOverrides", - test.NewPlugin(test.Ctx, "", "", test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(2)), test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name)), +var _ = Describe("applyOverridesToPreset", func() { + DescribeTable("test cases", + func(preset *greenhousev1alpha1.PluginPreset, clusterName string, expectedOptionValues []greenhousev1alpha1.PluginPresetPluginOptionValue) { + result := applyOverridesToPreset(preset, clusterName) + Expect(result.Spec.Plugin.OptionValues).To(Equal(expectedOptionValues)) + }, + + Entry("with no overrides defined", &greenhousev1alpha1.PluginPreset{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{greenhouseapis.LabelKeyOwnedBy: testTeam.Name}, + Spec: greenhousev1alpha1.PluginPresetSpec{ + Plugin: greenhousev1alpha1.PluginPresetPluginSpec{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("value-1")}, + }, + }, }, - Spec: greenhousev1alpha1.PluginPresetSpec{}, }, - test.NewPlugin(test.Ctx, "", "", test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(2)), test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name)), + clusterA, + []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("value-1")}, + }, ), - Entry("with defined pluginPresetOverrides but for another cluster", - test.NewPlugin(test.Ctx, "", clusterA, test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name), - test.WithCluster(clusterA), test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(2))), + + Entry("with overrides for a different cluster", &greenhousev1alpha1.PluginPreset{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{greenhouseapis.LabelKeyOwnedBy: testTeam.Name}, - }, Spec: greenhousev1alpha1.PluginPresetSpec{ + Plugin: greenhousev1alpha1.PluginPresetPluginSpec{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("value-1")}, + }, + }, ClusterOptionOverrides: []greenhousev1alpha1.ClusterOptionOverride{ { ClusterName: clusterB, - Overrides: []greenhousev1alpha1.PluginOptionValue{ - { - Name: "option-1", - Value: test.MustReturnJSONFor(1), - }, + Overrides: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("overridden")}, }, }, }, }, }, - test.NewPlugin(test.Ctx, "", clusterA, test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name), - test.WithCluster(clusterA), test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(2))), + clusterA, + []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("value-1")}, + }, ), - Entry("with defined pluginPresetOverrides for the correct cluster", - test.NewPlugin(test.Ctx, "", clusterA, test.WithCluster(clusterA), test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(2)), test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name)), + + Entry("with overrides for matching cluster - replaces existing value", &greenhousev1alpha1.PluginPreset{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{greenhouseapis.LabelKeyOwnedBy: testTeam.Name}, - }, Spec: greenhousev1alpha1.PluginPresetSpec{ + Plugin: greenhousev1alpha1.PluginPresetPluginSpec{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("original")}, + {Name: "option-2", Value: test.MustReturnJSONFor("unchanged")}, + }, + }, ClusterOptionOverrides: []greenhousev1alpha1.ClusterOptionOverride{ { ClusterName: clusterA, - Overrides: []greenhousev1alpha1.PluginOptionValue{ - { - Name: "option-1", - Value: test.MustReturnJSONFor(1), - }, + Overrides: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("overridden")}, }, }, }, }, }, - test.NewPlugin(test.Ctx, "", clusterA, test.WithCluster(clusterA), test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(1)), test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name)), + clusterA, + []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("overridden")}, + {Name: "option-2", Value: test.MustReturnJSONFor("unchanged")}, + }, ), - Entry("with defined pluginPresetOverrides for the cluster and plugin with empty option values", - test.NewPlugin(test.Ctx, "", clusterA, test.WithCluster(clusterA), test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name)), + + Entry("with overrides for matching cluster - appends new value", &greenhousev1alpha1.PluginPreset{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{greenhouseapis.LabelKeyOwnedBy: testTeam.Name}, - }, Spec: greenhousev1alpha1.PluginPresetSpec{ + Plugin: greenhousev1alpha1.PluginPresetPluginSpec{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("value-1")}, + }, + }, ClusterOptionOverrides: []greenhousev1alpha1.ClusterOptionOverride{ { ClusterName: clusterA, - Overrides: []greenhousev1alpha1.PluginOptionValue{ - { - Name: "option-1", - Value: test.MustReturnJSONFor(1), - }, + Overrides: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-new", Value: test.MustReturnJSONFor("new-value")}, }, }, }, }, }, - test.NewPlugin(test.Ctx, "", clusterA, test.WithCluster(clusterA), test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(1)), test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name)), + clusterA, + []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("value-1")}, + {Name: "option-new", Value: test.MustReturnJSONFor("new-value")}, + }, ), - Entry("with defined pluginPresetOverrides and plugin has two options", - test.NewPlugin(test.Ctx, "", clusterA, test.WithCluster(clusterA), test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(1)), test.WithPluginOptionValue("option-2", test.MustReturnJSONFor(1)), test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name)), + + Entry("with multiple overrides - replaces and appends", &greenhousev1alpha1.PluginPreset{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{greenhouseapis.LabelKeyOwnedBy: testTeam.Name}, - }, Spec: greenhousev1alpha1.PluginPresetSpec{ + Plugin: greenhousev1alpha1.PluginPresetPluginSpec{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor(1)}, + {Name: "option-2", Value: test.MustReturnJSONFor(2)}, + {Name: "option-3", Value: test.MustReturnJSONFor(3)}, + }, + }, ClusterOptionOverrides: []greenhousev1alpha1.ClusterOptionOverride{ { ClusterName: clusterA, - Overrides: []greenhousev1alpha1.PluginOptionValue{ - { - Name: "option-2", - Value: test.MustReturnJSONFor(2), - }, + Overrides: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-2", Value: test.MustReturnJSONFor(22)}, + {Name: "option-3", Value: test.MustReturnJSONFor(33)}, + {Name: "option-4", Value: test.MustReturnJSONFor(44)}, }, }, }, }, }, - test.NewPlugin(test.Ctx, "", clusterA, test.WithCluster(clusterA), test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(1)), test.WithPluginOptionValue("option-2", test.MustReturnJSONFor(2)), test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name)), + clusterA, + []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor(1)}, + {Name: "option-2", Value: test.MustReturnJSONFor(22)}, + {Name: "option-3", Value: test.MustReturnJSONFor(33)}, + {Name: "option-4", Value: test.MustReturnJSONFor(44)}, + }, ), - Entry("with defined pluginPresetOverrides has multiple options to override", - test.NewPlugin(test.Ctx, "", clusterA, test.WithCluster(clusterA), - test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(1)), - test.WithPluginOptionValue("option-2", test.MustReturnJSONFor(1)), - test.WithPluginOptionValue("option-3", test.MustReturnJSONFor(1)), - test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name)), + + Entry("with empty option values and overrides adds values", &greenhousev1alpha1.PluginPreset{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{greenhouseapis.LabelKeyOwnedBy: testTeam.Name}, - }, Spec: greenhousev1alpha1.PluginPresetSpec{ + Plugin: greenhousev1alpha1.PluginPresetPluginSpec{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{}, + }, ClusterOptionOverrides: []greenhousev1alpha1.ClusterOptionOverride{ { ClusterName: clusterA, - Overrides: []greenhousev1alpha1.PluginOptionValue{ - { - Name: "option-2", - Value: test.MustReturnJSONFor(2), - }, - { - Name: "option-3", - Value: test.MustReturnJSONFor(2), - }, - { - Name: "option-4", - Value: test.MustReturnJSONFor(2), - }, + Overrides: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("added")}, }, }, }, }, }, - test.NewPlugin(test.Ctx, "", clusterA, test.WithCluster(clusterA), test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name), - test.WithPluginOptionValue("option-1", test.MustReturnJSONFor(1)), - test.WithPluginOptionValue("option-2", test.MustReturnJSONFor(2)), - test.WithPluginOptionValue("option-3", test.MustReturnJSONFor(2)), - test.WithPluginOptionValue("option-4", test.MustReturnJSONFor(2))), + clusterA, + []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("added")}, + }, ), ) + + It("should not mutate the original preset", func() { + originalValue := test.MustReturnJSONFor("original") + preset := &greenhousev1alpha1.PluginPreset{ + Spec: greenhousev1alpha1.PluginPresetSpec{ + Plugin: greenhousev1alpha1.PluginPresetPluginSpec{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: originalValue}, + }, + }, + ClusterOptionOverrides: []greenhousev1alpha1.ClusterOptionOverride{ + { + ClusterName: clusterA, + Overrides: []greenhousev1alpha1.PluginPresetPluginOptionValue{ + {Name: "option-1", Value: test.MustReturnJSONFor("overridden")}, + }, + }, + }, + }, + } + + result := applyOverridesToPreset(preset, clusterA) + + // Result should have overridden value + Expect(result.Spec.Plugin.OptionValues[0].Value).To(Equal(test.MustReturnJSONFor("overridden"))) + + // Original preset should NOT be mutated + Expect(preset.Spec.Plugin.OptionValues[0].Value).To(Equal(originalValue), + "original preset should not be mutated by applyOverridesToPreset") + }) }) var _ = Describe("getReleaseName", func() { diff --git a/internal/controller/plugin/pluginpreset_values_resolver.go b/internal/controller/plugin/pluginpreset_values_resolver.go new file mode 100644 index 000000000..f49b3de8b --- /dev/null +++ b/internal/controller/plugin/pluginpreset_values_resolver.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package plugin + +import ( + "context" + "fmt" + "slices" + + 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/internal/util" + "github.com/cloudoperators/greenhouse/pkg/cel" +) + +// resolvePluginOptionValuesForPreset resolves expressions in a PluginPreset's +// option values before writing to Plugin. +func (r *PluginPresetReconciler) resolvePluginOptionValuesForPreset( + ctx context.Context, + preset *greenhousev1alpha1.PluginPreset, + cluster *greenhousev1alpha1.Cluster, +) ([]greenhousev1alpha1.PluginOptionValue, error) { + + if r.ExpressionEvaluationEnabled { + return r.resolveExpressionsForPreset(ctx, preset, cluster) + } + + for _, ov := range preset.Spec.Plugin.OptionValues { + if ov.Expression != nil { + return nil, fmt.Errorf("option %s has expression but expressionEvaluationEnabled is disabled for PluginPreset controller", ov.Name) + } + } + + return util.ConvertToPluginOptionValues(preset.Spec.Plugin.OptionValues), nil +} + +// 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) { + + hasExpressions := false + for _, ov := range preset.Spec.Plugin.OptionValues { + if ov.Expression != nil { + hasExpressions = true + break + } + } + if !hasExpressions { + return util.ConvertToPluginOptionValues(preset.Spec.Plugin.OptionValues), nil + } + + tempPlugin := greenhousev1alpha1.Plugin{ + ObjectMeta: metav1.ObjectMeta{ + Name: preset.Name, + Namespace: preset.Namespace, + Labels: preset.Labels, + }, + Spec: greenhousev1alpha1.PluginSpec{ + ClusterName: cluster.Name, + }, + } + greenhouseValuesList, err := helm.GetGreenhouseValues(ctx, r.Client, tempPlugin) + if err != nil { + return nil, fmt.Errorf("failed to get greenhouse values: %w", err) + } + templateData, err := helm.BuildTemplateData(greenhouseValuesList) + if err != nil { + return nil, fmt.Errorf("failed to build template data: %w", err) + } + result := make([]greenhousev1alpha1.PluginOptionValue, 0, len(preset.Spec.Plugin.OptionValues)) + for _, optionValue := range preset.Spec.Plugin.OptionValues { + if optionValue.Expression != nil { + 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) + } + result = append(result, greenhousev1alpha1.PluginOptionValue{ + Name: optionValue.Name, + Value: &apiextensionsv1.JSON{Raw: evaluatedValue}, + }) + } else { + ov := greenhousev1alpha1.PluginOptionValue{ + Name: optionValue.Name, + Value: optionValue.Value, + } + if optionValue.ValueFrom != nil { + ov.ValueFrom = &greenhousev1alpha1.PluginValueFromSource{ + Secret: optionValue.ValueFrom.Secret, + } + } + result = append(result, ov) + } + } + return result, nil +} + +// applyOverridesToPreset returns a copy of the preset with cluster-specific overrides merged. +func applyOverridesToPreset(preset *greenhousev1alpha1.PluginPreset, clusterName string) *greenhousev1alpha1.PluginPreset { + presetCopy := preset.DeepCopy() + + index := slices.IndexFunc(presetCopy.Spec.ClusterOptionOverrides, func(override greenhousev1alpha1.ClusterOptionOverride) bool { + return override.ClusterName == clusterName + }) + + if index == -1 { + return presetCopy + } + + for _, overrideValue := range presetCopy.Spec.ClusterOptionOverrides[index].Overrides { + valueIndex := slices.IndexFunc(presetCopy.Spec.Plugin.OptionValues, func(value greenhousev1alpha1.PluginPresetPluginOptionValue) bool { + return value.Name == overrideValue.Name + }) + + if valueIndex == -1 { + presetCopy.Spec.Plugin.OptionValues = append(presetCopy.Spec.Plugin.OptionValues, overrideValue) + } else { + presetCopy.Spec.Plugin.OptionValues[valueIndex] = overrideValue + } + } + + return presetCopy +} diff --git a/internal/controller/plugin/suite_test.go b/internal/controller/plugin/suite_test.go index 01d1e5391..76d68095e 100644 --- a/internal/controller/plugin/suite_test.go +++ b/internal/controller/plugin/suite_test.go @@ -39,7 +39,9 @@ var _ = BeforeSuite(func() { test.RegisterController("plugin", (&PluginReconciler{ KubeRuntimeOpts: clientutil.RuntimeOptions{QPS: 5, Burst: 10}, }).SetupWithManager) - test.RegisterController("pluginPreset", (&PluginPresetReconciler{}).SetupWithManager) + test.RegisterController("pluginPreset", (&PluginPresetReconciler{ + ExpressionEvaluationEnabled: true, + }).SetupWithManager) test.RegisterController("pluginDefinition", (&greenhouseDef.PluginDefinitionReconciler{}).SetupWithManager) test.RegisterController("clusterPluginDefinition", (&greenhouseDef.ClusterPluginDefinitionReconciler{}).SetupWithManager) test.RegisterController("cluster", (&greenhousecluster.RemoteClusterReconciler{}).SetupWithManager) diff --git a/internal/features/features.go b/internal/features/features.go index e52371f0d..3e637a16d 100644 --- a/internal/features/features.go +++ b/internal/features/features.go @@ -16,14 +16,16 @@ import ( ) const ( - DexFeatureKey = "dex" - PluginFeatureKey = "plugin" + DexFeatureKey = "dex" + PluginFeatureKey = "plugin" + PluginPresetFeatureKey = "pluginPreset" ) type Features struct { - raw map[string]string - dex *dexFeatures `yaml:"dex"` - plugin *pluginFeatures `yaml:"plugin"` + raw map[string]string + dex *dexFeatures `yaml:"dex"` + plugin *pluginFeatures `yaml:"plugin"` + pluginPreset *pluginPresetFeatures `yaml:"pluginPreset"` } type dexFeatures struct { @@ -36,6 +38,10 @@ type pluginFeatures struct { OCIMirroringEnabled bool `yaml:"ociMirroringEnabled"` } +type pluginPresetFeatures struct { + ExpressionEvaluationEnabled bool `yaml:"expressionEvaluationEnabled"` +} + func NewFeatures(ctx context.Context, k8sClient client.Reader, configMapName, namespace string) (*Features, error) { featureMap := &corev1.ConfigMap{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: namespace}, featureMap); err != nil { @@ -95,6 +101,33 @@ func (f *Features) resolvePluginFeatures() error { return nil } +func (f *Features) resolvePluginPresetFeatures() error { + pluginPreset, err := resolve[pluginPresetFeatures](f, PluginPresetFeatureKey) + if err != nil { + return err + } + f.pluginPreset = pluginPreset + return nil +} + +// IsPresetExpressionEvaluationEnabled returns whether CEL expression evaluation +// is enabled in the PluginPreset controller. +// Returns false as default. +func (f *Features) IsPresetExpressionEvaluationEnabled() bool { + if f == nil { + return false + } + + if f.pluginPreset != nil { + return f.pluginPreset.ExpressionEvaluationEnabled + } + if err := f.resolvePluginPresetFeatures(); err != nil { + ctrl.LoggerFrom(context.Background()).Error(err, "failed to resolve pluginPreset features") + return false + } + return f.pluginPreset.ExpressionEvaluationEnabled +} + // IsExpressionEvaluationEnabled returns whether plugin option expression evaluation is enabled. // Returns false as default. func (f *Features) IsExpressionEvaluationEnabled() bool { diff --git a/internal/features/features_test.go b/internal/features/features_test.go index 4983f063a..6d2ea4d40 100644 --- a/internal/features/features_test.go +++ b/internal/features/features_test.go @@ -242,3 +242,124 @@ func Test_PluginFeatures(t *testing.T) { }) } } + +func Test_PluginPresetFeatures(t *testing.T) { + type testCase struct { + name string + configMapData map[string]string + getError error + expectedExpressionEvaluation bool + } + testCases := []testCase{ + { + name: "it should return true when pluginPreset expression evaluation is enabled", + configMapData: map[string]string{PluginPresetFeatureKey: "expressionEvaluationEnabled: true\n"}, + expectedExpressionEvaluation: true, + }, + { + name: "it should return false when pluginPreset expression evaluation is disabled", + configMapData: map[string]string{PluginPresetFeatureKey: "expressionEvaluationEnabled: false\n"}, + expectedExpressionEvaluation: false, + }, + { + name: "it should return false when pluginPreset key is not found in feature-flags cm", + configMapData: map[string]string{"someOtherKey": "value\n"}, + expectedExpressionEvaluation: false, + }, + { + name: "it should return false when feature-flags cm is not found", + getError: apierrors.NewNotFound(schema.GroupResource{}, "configmap not found"), + expectedExpressionEvaluation: false, + }, + { + name: "it should return false when flag is malformed in feature-flags cm", + configMapData: map[string]string{PluginPresetFeatureKey: "expressionEvaluationEnabled:: invalid_yaml"}, + expectedExpressionEvaluation: false, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + ctx = log.IntoContext(ctx, log.Log) + + mockK8sClient := &mocks.MockClient{} + configMap := &corev1.ConfigMap{} + + if tc.getError != nil { + mockK8sClient.On("Get", ctx, types.NamespacedName{ + Name: clientutil.GetEnvOrDefault("FEATURE_FLAGS", "greenhouse-feature-flags"), Namespace: clientutil.GetEnvOrDefault("POD_NAMESPACE", "greenhouse"), + }, mock.Anything).Return(tc.getError) + } else { + configMap.Data = tc.configMapData + mockK8sClient.On("Get", ctx, types.NamespacedName{ + Name: clientutil.GetEnvOrDefault("FEATURE_FLAGS", "greenhouse-feature-flags"), Namespace: clientutil.GetEnvOrDefault("POD_NAMESPACE", "greenhouse"), + }, mock.Anything).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.ConfigMap) + *arg = *configMap + }).Return(nil) + } + + // Create Features instance + featuresInstance, err := NewFeatures(ctx, mockK8sClient, clientutil.GetEnvOrDefault("FEATURE_FLAGS", "greenhouse-feature-flags"), clientutil.GetEnvOrDefault("POD_NAMESPACE", "greenhouse")) + + if tc.getError != nil && client.IgnoreNotFound(tc.getError) == nil { + assert.NoError(t, client.IgnoreNotFound(err)) + assert.Nil(t, featuresInstance, "Expected nil when ConfigMap is missing") + + presetExpressionValue := featuresInstance.IsPresetExpressionEvaluationEnabled() + + assert.Equal(t, tc.expectedExpressionEvaluation, presetExpressionValue) + + mockK8sClient.AssertExpectations(t) + return + } + + assert.NoError(t, err) + + presetExpressionValue := featuresInstance.IsPresetExpressionEvaluationEnabled() + + // Assert expected values + assert.Equal(t, tc.expectedExpressionEvaluation, presetExpressionValue) + + // Verify plugin flags are NOT affected by pluginPreset flags + pluginExpressionValue := featuresInstance.IsExpressionEvaluationEnabled() + pluginIntegrationValue := featuresInstance.IsIntegrationEnabled() + assert.Equal(t, false, pluginExpressionValue, "plugin expression flag should be false when only pluginPreset is configured") + assert.Equal(t, false, pluginIntegrationValue, "plugin integration flag should be false when only pluginPreset is configured") + + mockK8sClient.AssertExpectations(t) + }) + } +} + +func Test_PluginAndPluginPresetFeaturesIndependent(t *testing.T) { + ctx := context.Background() + ctx = log.IntoContext(ctx, log.Log) + + mockK8sClient := &mocks.MockClient{} + configMap := &corev1.ConfigMap{ + Data: map[string]string{ + PluginFeatureKey: "expressionEvaluationEnabled: false\nintegrationEnabled: false\n", + PluginPresetFeatureKey: "expressionEvaluationEnabled: true\n", + }, + } + + mockK8sClient.On("Get", ctx, types.NamespacedName{ + Name: clientutil.GetEnvOrDefault("FEATURE_FLAGS", "greenhouse-feature-flags"), Namespace: clientutil.GetEnvOrDefault("POD_NAMESPACE", "greenhouse"), + }, mock.Anything).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.ConfigMap) + *arg = *configMap + }).Return(nil) + + featuresInstance, err := NewFeatures(ctx, mockK8sClient, clientutil.GetEnvOrDefault("FEATURE_FLAGS", "greenhouse-feature-flags"), clientutil.GetEnvOrDefault("POD_NAMESPACE", "greenhouse")) + assert.NoError(t, err) + + // Plugin flags should be false + assert.Equal(t, false, featuresInstance.IsExpressionEvaluationEnabled(), "plugin expression should be disabled") + assert.Equal(t, false, featuresInstance.IsIntegrationEnabled(), "plugin integration should be disabled") + + // PluginPreset flags should be true + assert.Equal(t, true, featuresInstance.IsPresetExpressionEvaluationEnabled(), "preset expression should be enabled") + + mockK8sClient.AssertExpectations(t) +} diff --git a/internal/helm/cel.go b/internal/helm/cel.go index e1a06254e..b917e927a 100644 --- a/internal/helm/cel.go +++ b/internal/helm/cel.go @@ -20,7 +20,7 @@ type CELResolver struct { // NewCELResolver creates a new CELResolver for a given Plugin. func NewCELResolver(optionValues []greenhousev1alpha1.PluginOptionValue) (*CELResolver, error) { - templateData, err := buildTemplateData(optionValues) + templateData, err := BuildTemplateData(optionValues) if err != nil { return nil, fmt.Errorf("failed to build template data: %w", err) } @@ -59,8 +59,8 @@ func (c *CELResolver) ResolveExpression(optionValue greenhousev1alpha1.PluginOpt }, nil } -// buildTemplateData extracts global.greenhouse.* values to build template data for CEL evaluation. -func buildTemplateData(optionValues []greenhousev1alpha1.PluginOptionValue) (map[string]any, error) { +// BuildTemplateData extracts global.greenhouse.* values to build template data for CEL evaluation. +func BuildTemplateData(optionValues []greenhousev1alpha1.PluginOptionValue) (map[string]any, error) { greenhouseValues := make([]greenhousev1alpha1.PluginOptionValue, 0) for _, optionValue := range optionValues { // Include global.greenhouse.* values for CEL evaluation. diff --git a/internal/test/resources.go b/internal/test/resources.go index 190eedd4b..29f7ed3cd 100644 --- a/internal/test/resources.go +++ b/internal/test/resources.go @@ -16,6 +16,7 @@ import ( greenhouseapis "github.com/cloudoperators/greenhouse/api" greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" greenhousev1alpha2 "github.com/cloudoperators/greenhouse/api/v1alpha2" + "github.com/cloudoperators/greenhouse/internal/util" ) const ( @@ -481,7 +482,7 @@ func WithPluginPresetPluginSpec(pluginSpec greenhousev1alpha1.PluginSpec) func(* return func(pp *greenhousev1alpha1.PluginPreset) { pp.Spec.Plugin.PluginDefinitionRef = pluginSpec.PluginDefinitionRef pp.Spec.Plugin.DisplayName = pluginSpec.DisplayName - pp.Spec.Plugin.OptionValues = pluginSpec.OptionValues + pp.Spec.Plugin.OptionValues = util.ConvertToPresetOptionValues(pluginSpec.OptionValues) pp.Spec.Plugin.ReleaseNamespace = pluginSpec.ReleaseNamespace pp.Spec.Plugin.ReleaseName = pluginSpec.ReleaseName pp.Spec.Plugin.DeletionPolicy = pluginSpec.DeletionPolicy @@ -489,6 +490,12 @@ func WithPluginPresetPluginSpec(pluginSpec greenhousev1alpha1.PluginSpec) func(* } } +func WithPresetPluginSpec(spec greenhousev1alpha1.PluginPresetPluginSpec) func(*greenhousev1alpha1.PluginPreset) { + return func(pp *greenhousev1alpha1.PluginPreset) { + pp.Spec.Plugin = spec + } +} + // WithPluginPresetLabel sets the label on a PluginPreset func WithPluginPresetLabel(key, value string) func(*greenhousev1alpha1.PluginPreset) { return func(pp *greenhousev1alpha1.PluginPreset) { @@ -510,7 +517,7 @@ func WithPluginPresetAnnotation(key, value string) func(*greenhousev1alpha1.Plug } // WithClusterOverrides sets the ClusterOverrides for a Cluster -func WithClusterOverride(clusterName string, optionValues []greenhousev1alpha1.PluginOptionValue) func(*greenhousev1alpha1.PluginPreset) { +func WithClusterOverride(clusterName string, optionValues []greenhousev1alpha1.PluginPresetPluginOptionValue) func(*greenhousev1alpha1.PluginPreset) { return func(pp *greenhousev1alpha1.PluginPreset) { for co := range pp.Spec.ClusterOptionOverrides { if pp.Spec.ClusterOptionOverrides[co].ClusterName == clusterName { diff --git a/internal/util/plugin.go b/internal/util/plugin.go new file mode 100644 index 000000000..7c1c0d66e --- /dev/null +++ b/internal/util/plugin.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" +) + +func ConvertToPluginOptionValues(presetValues []greenhousev1alpha1.PluginPresetPluginOptionValue) []greenhousev1alpha1.PluginOptionValue { + result := make([]greenhousev1alpha1.PluginOptionValue, 0, len(presetValues)) + for _, pv := range presetValues { + ov := greenhousev1alpha1.PluginOptionValue{ + Name: pv.Name, + Value: pv.Value, + } + + if pv.ValueFrom != nil { + ov.ValueFrom = &greenhousev1alpha1.PluginValueFromSource{ + Secret: pv.ValueFrom.Secret, + } + } + result = append(result, ov) + } + return result +} + +func ConvertToPresetOptionValues(values []greenhousev1alpha1.PluginOptionValue) []greenhousev1alpha1.PluginPresetPluginOptionValue { + result := make([]greenhousev1alpha1.PluginPresetPluginOptionValue, 0, len(values)) + for _, v := range values { + pv := greenhousev1alpha1.PluginPresetPluginOptionValue{ + Name: v.Name, + Value: v.Value, + } + if v.ValueFrom != nil { + pv.ValueFrom = &greenhousev1alpha1.PluginPresetPluginValueFromSource{ + Secret: v.ValueFrom.Secret, + } + } + result = append(result, pv) + } + return result +} diff --git a/internal/webhook/v1alpha1/pluginpreset_webhook.go b/internal/webhook/v1alpha1/pluginpreset_webhook.go index e89e885f0..3d4ef81e0 100644 --- a/internal/webhook/v1alpha1/pluginpreset_webhook.go +++ b/internal/webhook/v1alpha1/pluginpreset_webhook.go @@ -119,17 +119,35 @@ func validatePluginOptionValuesForPreset(pluginPreset *greenhousev1alpha1.Plugin var allErrs field.ErrorList optionValuesPath := field.NewPath("spec").Child("plugin").Child("optionValues") - errors := validatePluginOptionValues(pluginPreset.Spec.Plugin.OptionValues, pluginDefinitionName, pluginDefinitionSpec, false, optionValuesPath) + errors := validatePluginOptionValues(convertPresetToPluginOptionValues(pluginPreset.Spec.Plugin.OptionValues), pluginDefinitionName, pluginDefinitionSpec, false, optionValuesPath) allErrs = append(allErrs, errors...) for idx, overridesForSingleCluster := range pluginPreset.Spec.ClusterOptionOverrides { optionOverridesPath := field.NewPath("spec").Child("clusterOptionOverrides").Index(idx).Child("overrides") - errors = validatePluginOptionValues(overridesForSingleCluster.Overrides, pluginDefinitionName, pluginDefinitionSpec, false, optionOverridesPath) + errors = validatePluginOptionValues(convertPresetToPluginOptionValues(overridesForSingleCluster.Overrides), pluginDefinitionName, pluginDefinitionSpec, false, optionOverridesPath) allErrs = append(allErrs, errors...) } return allErrs } +func convertPresetToPluginOptionValues(presetValues []greenhousev1alpha1.PluginPresetPluginOptionValue) []greenhousev1alpha1.PluginOptionValue { + result := make([]greenhousev1alpha1.PluginOptionValue, 0, len(presetValues)) + for _, pv := range presetValues { + ov := greenhousev1alpha1.PluginOptionValue{ + Name: pv.Name, + Value: pv.Value, + Expression: pv.Expression, + } + if pv.ValueFrom != nil { + ov.ValueFrom = &greenhousev1alpha1.PluginValueFromSource{ + Secret: pv.ValueFrom.Secret, + } + } + result = append(result, ov) + } + return result +} + // validateWaitForPluginRefs validates that the WaitFor list is unique and that each PluginRef has exactly one field set. func validateWaitForPluginRefs(items []greenhousev1alpha1.WaitForItem, isPluginInCentralCluster bool) field.ErrorList { itemsPath := field.NewPath("spec", "waitFor") diff --git a/internal/webhook/v1alpha1/pluginpreset_webhook_test.go b/internal/webhook/v1alpha1/pluginpreset_webhook_test.go index 796b83606..47ea6eaf4 100644 --- a/internal/webhook/v1alpha1/pluginpreset_webhook_test.go +++ b/internal/webhook/v1alpha1/pluginpreset_webhook_test.go @@ -12,6 +12,7 @@ import ( greenhouseapis "github.com/cloudoperators/greenhouse/api" greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" "github.com/cloudoperators/greenhouse/internal/clientutil" + "github.com/cloudoperators/greenhouse/internal/local/utils" "github.com/cloudoperators/greenhouse/internal/test" ) @@ -233,7 +234,7 @@ var _ = Describe("PluginPreset Admission Tests", Ordered, func() { }) var _ = Describe("Validate Plugin OptionValues for PluginPreset", func() { - DescribeTable("Validate OptionValues in .Spec.Plugin contain either Value or ValueFrom", func(value *apiextensionsv1.JSON, valueFrom *greenhousev1alpha1.PluginValueFromSource, expErr bool) { + DescribeTable("Validate OptionValues in .Spec.Plugin contain exactly one of Value, ValueFrom, or Expression", func(value *apiextensionsv1.JSON, valueFrom *greenhousev1alpha1.PluginPresetPluginValueFromSource, expression *string, expErr bool) { pluginPreset := &greenhousev1alpha1.PluginPreset{ TypeMeta: metav1.TypeMeta{ Kind: "PluginPreset", @@ -249,11 +250,12 @@ var _ = Describe("Validate Plugin OptionValues for PluginPreset", func() { Name: "test", Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, }, - OptionValues: []greenhousev1alpha1.PluginOptionValue{ + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{ { - Name: "test", - Value: value, - ValueFrom: valueFrom, + Name: "test", + Value: value, + ValueFrom: valueFrom, + Expression: expression, }, }, }, @@ -269,6 +271,9 @@ var _ = Describe("Validate Plugin OptionValues for PluginPreset", func() { case valueFrom != nil: defaultVal = test.MustReturnJSONFor(valueFrom.Secret.Name) optionType = greenhousev1alpha1.PluginOptionTypeSecret + case expression != nil: + defaultVal = test.MustReturnJSONFor("expression-default") + optionType = greenhousev1alpha1.PluginOptionTypeString } pluginDefinition := &greenhousev1alpha1.ClusterPluginDefinition{ @@ -295,13 +300,17 @@ var _ = Describe("Validate Plugin OptionValues for PluginPreset", func() { Expect(errList).To(BeEmpty(), "expected no error, got %v", errList) } }, - Entry("Value and ValueFrom nil", nil, nil, true), - Entry("Value and ValueFrom not nil", test.MustReturnJSONFor("test"), &greenhousev1alpha1.PluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret"}}, true), - Entry("Value not nil", test.MustReturnJSONFor("test"), nil, false), - Entry("ValueFrom not nil", nil, &greenhousev1alpha1.PluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret", Key: "secret-key"}}, false), + Entry("Value and ValueFrom nil", nil, nil, nil, true), + Entry("Value and ValueFrom not nil", test.MustReturnJSONFor("test"), &greenhousev1alpha1.PluginPresetPluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret"}}, nil, true), + Entry("Value not nil", test.MustReturnJSONFor("test"), nil, nil, false), + Entry("ValueFrom not nil", nil, &greenhousev1alpha1.PluginPresetPluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret", Key: "secret-key"}}, nil, false), + Entry("Expression only (valid)", nil, nil, utils.StringP(`"test-${global.greenhouse.clusterName}"`), false), + Entry("Expression and Value both set (invalid)", test.MustReturnJSONFor("test"), nil, utils.StringP(`"test-expression"`), true), + Entry("Expression and ValueFrom both set (invalid)", nil, &greenhousev1alpha1.PluginPresetPluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret"}}, utils.StringP(`"test-expression"`), true), + Entry("All three set (invalid)", test.MustReturnJSONFor("test"), &greenhousev1alpha1.PluginPresetPluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret"}}, utils.StringP(`"test-expression"`), true), ) - DescribeTable("Validate OptionValues in .Spec.ClusterOptionOverrides contain either Value or ValueFrom", func(value *apiextensionsv1.JSON, valueFrom *greenhousev1alpha1.PluginValueFromSource, expErr bool) { + DescribeTable("Validate OptionValues in .Spec.ClusterOptionOverrides contain exactly one of Value, ValueFrom, or Expression", func(value *apiextensionsv1.JSON, valueFrom *greenhousev1alpha1.PluginPresetPluginValueFromSource, expression *string, expErr bool) { pluginPreset := &greenhousev1alpha1.PluginPreset{ TypeMeta: metav1.TypeMeta{ Kind: "PluginPreset", @@ -317,16 +326,17 @@ var _ = Describe("Validate Plugin OptionValues for PluginPreset", func() { Name: "test", Kind: greenhousev1alpha1.ClusterPluginDefinitionKind, }, - OptionValues: []greenhousev1alpha1.PluginOptionValue{}, + OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{}, }, ClusterOptionOverrides: []greenhousev1alpha1.ClusterOptionOverride{ { ClusterName: "test-cluster", - Overrides: []greenhousev1alpha1.PluginOptionValue{ + Overrides: []greenhousev1alpha1.PluginPresetPluginOptionValue{ { - Name: "test", - Value: value, - ValueFrom: valueFrom, + Name: "test", + Value: value, + ValueFrom: valueFrom, + Expression: expression, }, }, }, @@ -343,6 +353,9 @@ var _ = Describe("Validate Plugin OptionValues for PluginPreset", func() { case valueFrom != nil: defaultVal = test.MustReturnJSONFor(valueFrom.Secret.Name) optionType = greenhousev1alpha1.PluginOptionTypeSecret + case expression != nil: + defaultVal = test.MustReturnJSONFor("expression-default") + optionType = greenhousev1alpha1.PluginOptionTypeString } pluginDefinition := &greenhousev1alpha1.ClusterPluginDefinition{ @@ -369,10 +382,14 @@ var _ = Describe("Validate Plugin OptionValues for PluginPreset", func() { Expect(errList).To(BeEmpty(), "expected no error, got %v", errList) } }, - Entry("Value and ValueFrom nil", nil, nil, true), - Entry("Value and ValueFrom not nil", test.MustReturnJSONFor("test"), &greenhousev1alpha1.PluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret"}}, true), - Entry("Value not nil", test.MustReturnJSONFor("test"), nil, false), - Entry("ValueFrom not nil", nil, &greenhousev1alpha1.PluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret", Key: "secret-key"}}, false), + Entry("Value and ValueFrom nil", nil, nil, nil, true), + Entry("Value and ValueFrom not nil", test.MustReturnJSONFor("test"), &greenhousev1alpha1.PluginPresetPluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret"}}, nil, true), + Entry("Value not nil", test.MustReturnJSONFor("test"), nil, nil, false), + Entry("ValueFrom not nil", nil, &greenhousev1alpha1.PluginPresetPluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret", Key: "secret-key"}}, nil, false), + Entry("Expression only (valid)", nil, nil, utils.StringP(`"test-${global.greenhouse.clusterName}"`), false), + Entry("Expression and Value both set (invalid)", test.MustReturnJSONFor("test"), nil, utils.StringP(`"test-expression"`), true), + Entry("Expression and ValueFrom both set (invalid)", nil, &greenhousev1alpha1.PluginPresetPluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret"}}, utils.StringP(`"test-expression"`), true), + Entry("All three set (invalid)", test.MustReturnJSONFor("test"), &greenhousev1alpha1.PluginPresetPluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret"}}, utils.StringP(`"test-expression"`), true), ) DescribeTable("Validate WaitFor PluginRefs", func(waitForItems []greenhousev1alpha1.WaitForItem, expErr bool) { diff --git a/types/typescript/schema.d.ts b/types/typescript/schema.d.ts index b5f0ba862..002658326 100644 --- a/types/typescript/schema.d.ts +++ b/types/typescript/schema.d.ts @@ -842,12 +842,7 @@ export interface components { clusterOptionOverrides?: { clusterName: string; overrides: { - /** - * @description Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. - * - * Deprecated: Expression is deprecated on standalone Plugins and will be removed in a future release. - * Consider using a PluginPreset to deploy Plugins utilizing the Expression field. - */ + /** @description Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. */ expression?: string; /** @description Name of the values. */ name: string; @@ -855,12 +850,7 @@ export interface components { value?: unknown; /** @description ValueFrom references value in another source. */ valueFrom?: { - /** - * @description Ref references values defined in another resource (Plugin, PluginPreset) - * - * Deprecated: Ref is deprecated on standalone Plugins and will be removed in a future release. - * Consider using a PluginPreset to deploy Plugins utilizing the Ref field. - */ + /** @description Ref references values defined in another resource (Plugin, PluginPreset) */ ref?: { /** @description Expression is a CEL expression to extract the value from the referenced resource */ expression: string; @@ -982,12 +972,7 @@ export interface components { }[]; /** @description Values are the values for a PluginDefinition instance. */ optionValues?: { - /** - * @description Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. - * - * Deprecated: Expression is deprecated on standalone Plugins and will be removed in a future release. - * Consider using a PluginPreset to deploy Plugins utilizing the Expression field. - */ + /** @description Expression is a YAML string with ${...} placeholders that will be evaluated as CEL expressions. */ expression?: string; /** @description Name of the values. */ name: string; @@ -995,12 +980,7 @@ export interface components { value?: unknown; /** @description ValueFrom references value in another source. */ valueFrom?: { - /** - * @description Ref references values defined in another resource (Plugin, PluginPreset) - * - * Deprecated: Ref is deprecated on standalone Plugins and will be removed in a future release. - * Consider using a PluginPreset to deploy Plugins utilizing the Ref field. - */ + /** @description Ref references values defined in another resource (Plugin, PluginPreset) */ ref?: { /** @description Expression is a CEL expression to extract the value from the referenced resource */ expression: string; @@ -1093,6 +1073,8 @@ export interface components { status?: { /** @description FailedPlugins is the number of failed Plugins managed by the PluginPreset. */ failedPlugins?: number; + /** @description PluginDefinitionVersion is the version of the PluginDefinition referenced by this PluginPreset. */ + pluginDefinitionVersion?: string; /** @description PluginStatuses contains statuses of Plugins managed by the PluginPreset. */ pluginStatuses?: { pluginName?: string;