diff --git a/api/v1alpha1/pluginpreset_types.go b/api/v1alpha1/pluginpreset_types.go index 579072700..0615f1ff0 100644 --- a/api/v1alpha1/pluginpreset_types.go +++ b/api/v1alpha1/pluginpreset_types.go @@ -62,7 +62,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. @@ -114,8 +114,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 f7b889c7a..2bca20dff 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 bc9445a14..628600c5a 100644 --- a/charts/manager/crds/greenhouse.sap_pluginpresets.yaml +++ b/charts/manager/crds/greenhouse.sap_pluginpresets.yaml @@ -68,14 +68,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. @@ -87,11 +85,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 @@ -307,14 +302,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. @@ -326,11 +319,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..87043f10d 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" $ }} 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 67f0c179c..958efd535 100644 --- a/docs/reference/api/index.html +++ b/docs/reference/api/index.html @@ -1082,8 +1082,8 @@

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

PluginOptionValue

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

PluginOptionValue is the value for a PluginOption.

@@ -3260,6 +3258,11 @@

PluginPreset

PluginPresetPluginOptionValue

+

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

PluginPresetPluginOptionValue is the value for a PluginOption.

@@ -3370,8 +3373,8 @@

PluginPresetPluginSpec optionValues
- -[]PluginOptionValue + +[]PluginPresetPluginOptionValue diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml index 95a099a30..619f332ab 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..01a9f35f4 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,8 +89,117 @@ 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 in PluginPresets requires the feature flag `pluginPreset.expressionEvaluationEnabled` to be set to `true` in the Greenhouse feature flags ConfigMap. + +When disabled (default: `false`), expressions are not evaluated by the PluginPreset controller and are copied to the created Plugin as `expression` fields. + +```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) - [Plugin reference](./../plugin) -- [PluginDefinition reference](./../plugindefinition) +- [PluginDefinition reference](./../plugindefinition) \ No newline at end of file diff --git a/internal/cmd/plugin_template.go b/internal/cmd/plugin_template.go index e12773a2d..cda18866a 100644 --- a/internal/cmd/plugin_template.go +++ b/internal/cmd/plugin_template.go @@ -190,7 +190,7 @@ func (o *PluginTemplatePresetOptions) prepareValues() error { ) // Merge PluginPreset values. - values = helminternal.MergePluginOptionValues(values, o.pluginPreset.Spec.Plugin.OptionValues) + values = helminternal.MergePluginOptionValues(values, convertToPluginOptionValues(o.pluginPreset.Spec.Plugin.OptionValues)) // Merge cluster overrides. values = helminternal.MergePluginOptionValues(values, o.getClusterSpecificOverrides()) @@ -205,6 +205,29 @@ func (o *PluginTemplatePresetOptions) prepareValues() error { return nil } +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.Expression != nil { + ov.Expression = pv.Expression + } + if pv.ValueFrom != nil { + ov.ValueFrom = &greenhousev1alpha1.PluginValueFromSource{ + Secret: pv.ValueFrom.Secret, + } + if pv.ValueFrom.Ref != nil { + ov.ValueFrom.Ref = pv.ValueFrom.Ref + } + } + result = append(result, ov) + } + return result +} + func (o *PluginTemplatePresetOptions) runHelmTemplate(valuesFile string) error { chartRef := o.pluginDefinition.Spec.HelmChart @@ -289,7 +312,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 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/plugin_controller_flux.go b/internal/controller/plugin/plugin_controller_flux.go index 38dd8218c..e4375b5cc 100644 --- a/internal/controller/plugin/plugin_controller_flux.go +++ b/internal/controller/plugin/plugin_controller_flux.go @@ -469,10 +469,14 @@ func computeReleaseValues(ctx context.Context, c client.Client, plugin *greenhou continue case v.Expression != nil: - if !expressionEvaluation { - // skip expression evaluation if not enabled - continue + if celResolver == nil { + celResolver, err = helm.NewCELResolver(optionValues) + if err != nil { + return nil, fmt.Errorf("failed to initialize CEL resolver: %w", err) + } } + // This PR adds CEL expression evaluation to the PluginPreset controller (#1774). + // The Plugin controller's expression evaluation remains active (gated by feature flag) resolvedOptionValue, err := celResolver.ResolveExpression(v, expressionEvaluation) if err != nil { return nil, err @@ -480,7 +484,9 @@ func computeReleaseValues(ctx context.Context, c client.Client, plugin *greenhou optionValues[i] = *resolvedOptionValue case v.ValueFrom != nil && v.ValueFrom.Ref != nil: - // skip if integration flag is not enabled + // TODO(#1776): References should no longer be resolved by Plugin controller. + // Once PluginPreset controller handles all reference resolution, + // this branch should return an error instead of resolving. if !integrationEnabled { continue } diff --git a/internal/controller/plugin/pluginpreset_controller.go b/internal/controller/plugin/pluginpreset_controller.go index 84c9a8579..394ed0e42 100644 --- a/internal/controller/plugin/pluginpreset_controller.go +++ b/internal/controller/plugin/pluginpreset_controller.go @@ -44,7 +44,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 @@ -54,6 +55,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 { @@ -238,12 +240,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 { @@ -333,30 +342,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()) @@ -515,7 +500,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: convertToPluginOptionValues(preset.Spec.Plugin.OptionValues), ReleaseNamespace: preset.Spec.Plugin.ReleaseNamespace, DeletionPolicy: preset.Spec.Plugin.DeletionPolicy, IgnoreDifferences: preset.Spec.Plugin.IgnoreDifferences, @@ -525,3 +510,30 @@ func pluginSpecFromPluginPreset(preset *greenhousev1alpha1.PluginPreset, cluster WaitFor: preset.Spec.WaitFor, } } + +// Convert PluginPresetPluginOptionValue → PluginOptionValue for the Plugin +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.Expression != nil { + ov.Expression = pv.Expression + } + + if pv.ValueFrom != nil { + ov.ValueFrom = &greenhousev1alpha1.PluginValueFromSource{ + Secret: pv.ValueFrom.Secret, + } + + if pv.ValueFrom.Ref != nil { + ov.ValueFrom.Ref = pv.ValueFrom.Ref + } + } + result = append(result, ov) + } + return result +} diff --git a/internal/controller/plugin/pluginpreset_controller_test.go b/internal/controller/plugin/pluginpreset_controller_test.go index 1cbaf0874..6c3807576 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, @@ -550,7 +554,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") @@ -890,152 +897,427 @@ 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"` + 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": 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.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(`"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"` + 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": 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}"` + 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()) + + 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"` + 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()) + + 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) + }) }) -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..da5ab58af --- /dev/null +++ b/internal/controller/plugin/pluginpreset_values_resolver.go @@ -0,0 +1,122 @@ +// 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/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) + } + return 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 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, + Ref: optionValue.ValueFrom.Ref, + } + } + 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..65ada08d6 100644 --- a/internal/test/resources.go +++ b/internal/test/resources.go @@ -481,7 +481,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 = convertToPresetOptionValues(pluginSpec.OptionValues) pp.Spec.Plugin.ReleaseNamespace = pluginSpec.ReleaseNamespace pp.Spec.Plugin.ReleaseName = pluginSpec.ReleaseName pp.Spec.Plugin.DeletionPolicy = pluginSpec.DeletionPolicy @@ -489,6 +489,26 @@ func WithPluginPresetPluginSpec(pluginSpec greenhousev1alpha1.PluginSpec) func(* } } +// convertToPresetOptionValues converts []PluginOptionValue to []PluginPresetPluginOptionValue +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, + Expression: v.Expression, + } + if v.ValueFrom != nil { + pv.ValueFrom = &greenhousev1alpha1.PluginPresetPluginValueFromSource{ + Secret: v.ValueFrom.Secret, + Ref: v.ValueFrom.Ref, + } + } + result = append(result, pv) + } + return result +} + // WithPluginPresetLabel sets the label on a PluginPreset func WithPluginPresetLabel(key, value string) func(*greenhousev1alpha1.PluginPreset) { return func(pp *greenhousev1alpha1.PluginPreset) { @@ -512,15 +532,16 @@ 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) { return func(pp *greenhousev1alpha1.PluginPreset) { + presetOverrides := convertToPresetOptionValues(optionValues) for co := range pp.Spec.ClusterOptionOverrides { if pp.Spec.ClusterOptionOverrides[co].ClusterName == clusterName { - pp.Spec.ClusterOptionOverrides[co].Overrides = optionValues + pp.Spec.ClusterOptionOverrides[co].Overrides = presetOverrides return } } pp.Spec.ClusterOptionOverrides = append(pp.Spec.ClusterOptionOverrides, greenhousev1alpha1.ClusterOptionOverride{ ClusterName: clusterName, - Overrides: optionValues, + Overrides: presetOverrides, }) } } diff --git a/internal/webhook/v1alpha1/pluginpreset_webhook.go b/internal/webhook/v1alpha1/pluginpreset_webhook.go index 4d1ddc4ee..c291dd5b0 100644 --- a/internal/webhook/v1alpha1/pluginpreset_webhook.go +++ b/internal/webhook/v1alpha1/pluginpreset_webhook.go @@ -142,17 +142,38 @@ 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, + } + if pv.ValueFrom.Ref != nil { + ov.ValueFrom.Ref = pv.ValueFrom.Ref + } + } + 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 024169028..0d3fb39bf 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" ) @@ -268,7 +269,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", @@ -284,11 +285,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, }, }, }, @@ -304,6 +306,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{ @@ -330,13 +335,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", @@ -352,16 +361,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, }, }, }, @@ -378,6 +388,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{ @@ -404,10 +417,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 5f3987e5c..6f78c16ff 100644 --- a/types/typescript/schema.d.ts +++ b/types/typescript/schema.d.ts @@ -944,8 +944,6 @@ export interface components { deletionPolicy: "Delete" | "Retain"; /** @description PluginSpec is the spec of the plugin to be deployed by the PluginPreset. */ plugin: { - /** @description ClusterName is the name of the cluster the plugin is deployed to. If not set, the plugin is deployed to the greenhouse cluster. */ - clusterName?: string; /** * @description DeletionPolicy defines how Helm Releases created by a Plugin are handled upon deletion of the Plugin. * Supported values are "Delete" and "Retain". If not set, defaults to "Delete". @@ -1059,16 +1057,6 @@ export interface components { * Defaults to the Greenhouse managed namespace if not set. */ releaseNamespace?: string; - /** @description WaitFor defines other Plugins to wait for before installing this Plugin. */ - waitFor?: { - /** @description PluginRef defines a reference to the Plugin. */ - pluginRef: { - /** @description Name of the Plugin. */ - name?: string; - /** @description PluginPreset is the name of the PluginPreset which creates the Plugin. */ - pluginPreset?: string; - }; - }[]; }; /** @description WaitFor defines other Plugins to wait for before creating the Plugin. */ waitFor?: { @@ -1198,7 +1186,12 @@ 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. */ + /** + * @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. + */ expression?: string; /** @description Name of the values. */ name: string; @@ -1206,7 +1199,12 @@ export interface components { value?: unknown; /** @description ValueFrom references value in another source. */ valueFrom?: { - /** @description Ref references values defined in another resource (Plugin, PluginPreset) */ + /** + * @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. + */ ref?: { /** @description Expression is a CEL expression to extract the value from the referenced resource */ expression: string;