-
imageReplication
[]string
@@ -4007,8 +3981,7 @@ PluginValueFromSource
(Appears on:
PluginOptionValue)
-PluginValueFromSource defines how to extract dynamic values
-only one of secret or ref can be set
+PluginValueFromSource defines how to extract dynamic values
@@ -4032,21 +4005,6 @@ PluginValueFromSource
Secret references the v1.Secret containing the value that needs to be extracted
-
-
-ref
-
-
-ExternalValueSource
-
-
- |
-
- 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.
- |
-
diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml
index 82e5ab6a0..0c8fa015c 100755
--- a/docs/reference/api/openapi.yaml
+++ b/docs/reference/api/openapi.yaml
@@ -1641,13 +1641,6 @@ components:
items:
description: PluginOptionValue 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.
- type: string
name:
description: Name of the values.
type: string
@@ -1657,82 +1650,6 @@ components:
valueFrom:
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.
- properties:
- expression:
- description: Expression is a CEL expression to extract the value from the referenced resource
- type: string
- kind:
- description: |-
- Kind is the resource kind to target
- if not set, defaults to the same kind as the referencing resource (Plugin or PluginPreset)
- enum:
- - Plugin
- - PluginPreset
- type: string
- name:
- description: |-
- Name is the name of the resource to target
- this field is mutually exclusive with LabelSelector
- minLength: 1
- type: string
- selector:
- description: |-
- Selector selects the resources to target based on labels
- this field is mutually exclusive with Name
- properties:
- matchExpressions:
- description: matchExpressions is a list of label selector requirements. The requirements are ANDed.
- items:
- description: |-
- A label selector requirement is a selector that contains values, a key, and an operator that
- relates the key and values.
- properties:
- key:
- description: key is the label key that the selector applies to.
- type: string
- operator:
- description: |-
- operator represents a key's relationship to a set of values.
- Valid operators are In, NotIn, Exists and DoesNotExist.
- type: string
- values:
- description: |-
- values is an array of string values. If the operator is In or NotIn,
- the values array must be non-empty. If the operator is Exists or DoesNotExist,
- the values array must be empty. This array is replaced during a strategic
- merge patch.
- items:
- type: string
- type: array
- x-kubernetes-list-type: atomic
- required:
- - key
- - operator
- type: object
- type: array
- x-kubernetes-list-type: atomic
- matchLabels:
- additionalProperties:
- type: string
- description: |-
- matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
- map is equivalent to an element of matchExpressions, whose key field is "key", the
- operator is "In", and the values array contains only "value". The requirements are ANDed.
- type: object
- type: object
- x-kubernetes-map-type: atomic
- required:
- - expression
- type: object
- x-kubernetes-validations:
- - message: exactly one of the fields in [name selector] must be set
- rule: '[has(self.name),has(self.selector)].filter(x,x==true).size() == 1'
secret:
description: Secret references the v1.Secret containing the value that needs to be extracted
properties:
@@ -1748,10 +1665,8 @@ components:
type: object
type: object
x-kubernetes-validations:
- - message: both secret and ref cannot be set
- rule: '!(has(self.secret) && has(self.ref))'
- - message: one of secret or ref must be set
- rule: has(self.secret) || has(self.ref)
+ - message: secret must be set
+ rule: has(self.secret)
required:
- name
type: object
@@ -1931,13 +1846,6 @@ components:
- type
x-kubernetes-list-type: map
type: object
- trackedObjects:
- description: |-
- TrackedObjects contains a list of objects being tracked via the greenhouse.sap/tracking-id annotation.
- Each entry is in the format "kind/name" (e.g., "Plugin/my-plugin").
- items:
- type: string
- type: array
uiApplication:
description: UIApplication contains a reference to the frontend that is used for the deployed pluginDefinition version.
properties:
diff --git a/docs/reference/components/plugin.md b/docs/reference/components/plugin.md
index 631d29d19..91c3c4f30 100644
--- a/docs/reference/components/plugin.md
+++ b/docs/reference/components/plugin.md
@@ -60,16 +60,6 @@ spec:
> :information_source: A defaulting webhook automatically merges the OptionValues with the defaults set in the PluginDefinition. The defaulting does not update OptionValues when the defaults change and does not remove values when they are removed from the PluginDefinition. Only when the Plugin is managed via a PluginPreset, the OptionValues will be automatically updated when the defaults in the PluginDefinition change. Standalone Plugins will need to be reconciled by annotating with the [reconcile annotation](./../plugin/#triggering-reconciliation-of-the-plugins-managed-resources) to apply the new defaults.
-`.spec.optionValues[].expression` is an optional field that allows you to define dynamic values using [CEL (Common Expression Language)](https://github.com/google/cel-spec) expressions. Expressions use `${...}` placeholders that reference `global.greenhouse.*` variables such as `global.greenhouse.clusterName` or [Cluster Metadata](./../cluster#setting-metadata-labels) via `global.greenhouse.metadata.*`. For available CEL string functions, see the [CEL string extension documentation](https://github.com/google/cel-go/tree/master/ext#strings). See [Using Metadata Labels and Expressions](./../../../user-guides/plugin/metadata-expressions) for detailed examples.
-
-```yaml
- optionValues:
- - name: endpoint
- expression: "https://api.${global.greenhouse.metadata.region}.example.com"
-```
-
-> :warning: CEL expression evaluation requires the `expressionEvaluationEnabled` feature flag to be enabled. When disabled, expressions are treated as literal strings.
-
`.spec.waitFor` is an optional field that specifies PluginPresets or Plugins which have to be successfully deployed before this Plugin can be deployed. This can be used to express dependencies between Plugins. This can be useful if one Plugin depends on Custom Resource Definitions or other resources created by another Plugin.
```yaml
diff --git a/docs/reference/components/pluginpreset.md b/docs/reference/components/pluginpreset.md
index 11054b4c5..00bfc6fc6 100644
--- a/docs/reference/components/pluginpreset.md
+++ b/docs/reference/components/pluginpreset.md
@@ -91,9 +91,7 @@ spec:
## 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.
+PluginPresets support CEL (Common Expression Language) expressions in `optionValues`. 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:
@@ -176,13 +174,13 @@ spec:
"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.
-
+> :information_source: Expressions are only evaluated in PluginPresets.
## Feature Flag
-CEL expression evaluation is disabled by default. To enable it, set `pluginPreset.expressionEvaluationEnabled: true` in the Greenhouse feature flags ConfigMap.
+CEL expression evaluation in PluginPresets requires the feature flag `pluginPreset.expressionEvaluationEnabled` to be set to `true` in the Greenhouse feature flags ConfigMap.
+
+By default, this flag is `false` if it is unset or invalid. When disabled, PluginPresets containing expressions will be rejected with an error.
```yaml
# greenhouse-feature-flags ConfigMap
@@ -196,6 +194,173 @@ data:
expressionEvaluationEnabled: true
```
+## ValueFrom References Between PluginPresets
+
+PluginPresets can reference option values from other PluginPresets using `valueFrom.ref`.
+This enables one PluginPreset to use resolved values from another, including values generated by CEL expressions.
+
+### Reference by Name
+
+```yaml
+# Source PluginPreset - generates a hostname per cluster
+apiVersion: greenhouse.sap/v1alpha1
+kind: PluginPreset
+metadata:
+ name: backend-preset
+spec:
+ plugin:
+ pluginDefinitionRef:
+ name: backend-service
+ optionValues:
+ - name: backend.hostname
+ expression: |
+ "backend.${global.greenhouse.clusterName}.example.com"
+ clusterSelector:
+ matchLabels:
+ env: production
+---
+# Consumer PluginPreset - references the backend hostname
+apiVersion: greenhouse.sap/v1alpha1
+kind: PluginPreset
+metadata:
+ name: frontend-preset
+spec:
+ plugin:
+ pluginDefinitionRef:
+ name: frontend-service
+ optionValues:
+ - name: frontend.backendUrl
+ valueFrom:
+ ref:
+ kind: PluginPreset
+ name: backend-preset
+ expression: |
+ ${spec.optionValues.filter(v, v.name == "backend.hostname")[0].value}
+ clusterSelector:
+ matchLabels:
+ env: production
+```
+
+The resulting Plugin will contain the resolved value. For example:
+
+```yaml
+# If the matching cluster is named "production-eu":
+spec:
+ optionValues:
+ - name: frontend.backendUrl
+ value: "backend.production-eu.example.com"
+```
+
+### Reference by Label Selector
+When multiple PluginPresets need to be referenced, use a label selector.
+The CEL expression is evaluated against each matching PluginPreset and results are collected into an array.
+
+
+```yaml
+# Multiple source PluginPresets with shared label
+apiVersion: greenhouse.sap/v1alpha1
+kind: PluginPreset
+metadata:
+ name: selector-source-a
+ namespace: demo
+ labels:
+ e2e.greenhouse.sap/selector-test: "true"
+spec:
+ plugin:
+ pluginDefinitionRef:
+ name: perses
+ releaseName: perses-sel-source-a
+ releaseNamespace: kube-monitoring
+ optionValues:
+ - name: source.endpoint
+ expression: |
+ "endpoint-a.${global.greenhouse.clusterName}.example.com"
+ clusterSelector:
+ matchLabels:
+ greenhouse.sap/cluster: kind-greenhouse-remote
+---
+apiVersion: greenhouse.sap/v1alpha1
+kind: PluginPreset
+metadata:
+ name: selector-source-b
+ namespace: demo
+ labels:
+ e2e.greenhouse.sap/selector-test: "true"
+spec:
+ plugin:
+ pluginDefinitionRef:
+ name: perses
+ releaseName: perses-sel-source-b
+ releaseNamespace: kube-monitoring
+ optionValues:
+ - name: source.endpoint
+ expression: |
+ "endpoint-b.${global.greenhouse.clusterName}.example.com"
+ clusterSelector:
+ matchLabels:
+ greenhouse.sap/cluster: kind-greenhouse-remote
+---
+apiVersion: greenhouse.sap/v1alpha1
+kind: PluginPreset
+metadata:
+ name: selector-consumer
+ namespace: demo
+spec:
+ plugin:
+ pluginDefinitionRef:
+ name: perses
+ releaseName: perses-sel-consumer
+ releaseNamespace: kube-monitoring
+ optionValues:
+ - name: consumer.endpoints
+ valueFrom:
+ ref:
+ kind: PluginPreset
+ selector:
+ matchLabels:
+ e2e.greenhouse.sap/selector-test: "true"
+ expression: |
+ ${spec.optionValues.filter(v, v.name == "source.endpoint")[0].value}
+ clusterSelector:
+ matchLabels:
+ greenhouse.sap/cluster: kind-greenhouse-remote
+```
+
+The consumer Plugin will receive an array of all resolved values:
+
+```yaml
+spec:
+ optionValues:
+ - name: consumer.endpoints
+ value: [ "endpoint-a.kind-greenhouse-remote.example.com",
+ "endpoint-b.kind-greenhouse-remote.example.com"]
+```
+
+### CEL Expression Syntax for References
+The expression field in valueFrom.ref supports multiple syntax styles:
+
+#### New simplified syntax
+`expression: spec.optionValues.filter(v, v.name == "my.value")[0].value`
+
+#### With ${...} wrapper
+`expression: ${spec.optionValues.filter(v, v.name == "my.value")[0].value}`
+
+#### Legacy syntax (backward compatible)
+`expression: object.spec.optionValues.filter(v, v.name == "my.value")[0].value`
+
+
+> :warning: ValueFrom references in PluginPresets only support referencing other PluginPresets (kind: PluginPreset). Referencing standalone Plugins is not supported.
+
+### Feature Flags
+Expression evaluation and ValueFrom.Ref resolution in PluginPresets are controlled by feature flags:
+
+```yaml
+# greenhouse-feature-flags ConfigMap
+pluginPreset: |
+ expressionEvaluationEnabled: true
+ integrationEnabled: true
+```
+
## Next Steps
- [Managing Plugins for multiple clusters](./../../../user-guides/plugin/plugin-management)
diff --git a/docs/user-guides/plugin/metadata-expressions.md b/docs/user-guides/plugin/metadata-expressions.md
index 55df3f053..4ba5fef57 100644
--- a/docs/user-guides/plugin/metadata-expressions.md
+++ b/docs/user-guides/plugin/metadata-expressions.md
@@ -8,77 +8,10 @@ description: >
## Overview
-Greenhouse allows you to define metadata labels on Clusters and use them in Plugin configurations through CEL (Common Expression Language) expressions. This enables dynamic configuration of Plugins based on cluster-specific attributes like region, environment, or any custom metadata.
+Greenhouse allows you to define metadata labels on Clusters and use them in PluginPreset configurations through CEL (Common Expression Language) expressions. This enables dynamic configuration of Plugins based on cluster-specific attributes like region, environment, or any custom metadata.
For information on setting metadata labels on Clusters, see [Setting Metadata Labels](./../../../reference/components/cluster#setting-metadata-labels).
-## Using CEL Expressions in Plugins
-
-Once metadata labels are set on a Cluster, you can reference them in Plugin optionValues using the `expression` field.
-
-### Basic String Interpolation
-
-Use `${...}` placeholders to insert metadata values into strings:
-
-```yaml
-apiVersion: greenhouse.sap/v1alpha1
-kind: Plugin
-metadata:
- name: example-plugin
- namespace: example-organization
-spec:
- clusterName: example-cluster
- pluginDefinitionRef:
- kind: PluginDefinition
- name: example-app
- optionValues:
- - name: endpoint
- expression: "https://api.${global.greenhouse.metadata.region}.example.com"
- - name: username
- expression: "service-${global.greenhouse.metadata.environment}-user"
-```
-
-With cluster metadata labels `metadata.greenhouse.sap/region: europe` and `metadata.greenhouse.sap/environment: production`, this resolves to:
-- `endpoint`: `"https://api.europe.example.com"`
-- `username`: `"service-production-user"`
-
-### Complex YAML Values
-
-Expressions can produce complex YAML structures:
-
-```yaml
-optionValues:
- - name: config
- expression: |
- cluster: ${global.greenhouse.clusterName}
- region: ${global.greenhouse.metadata.region}
- environment: ${global.greenhouse.metadata.environment}
- endpoints:
- api: https://api.${global.greenhouse.metadata.region}.example.com
- metrics: https://metrics.${global.greenhouse.metadata.region}.example.com
-```
-
-### Using CEL Functions
-
-CEL string functions can transform values:
-
-```yaml
-optionValues:
- # Convert to uppercase
- - name: clusterLabel
- expression: ${global.greenhouse.clusterName.upperAscii()}
-
- # Split and rejoin with different delimiter
- - name: normalizedName
- expression: ${global.greenhouse.clusterName.split('-').join('_')}
-
- # Check if environment contains a substring
- - name: isProduction
- expression: ${global.greenhouse.metadata.environment.contains('prod')}
-```
-
-For a complete list of available CEL string functions, see the [CEL string extension documentation](https://github.com/google/cel-go/tree/master/ext#strings).
-
## Using Expressions with PluginPresets
Expressions are particularly powerful with PluginPresets, allowing you to deploy Plugins to multiple clusters with cluster-specific configurations:
@@ -126,6 +59,27 @@ The following `global.greenhouse.*` variables are available in expressions:
| `global.greenhouse.baseDomain` | DNS base domain for Greenhouse |
| `global.greenhouse.metadata.*` | Cluster metadata labels |
+### Using CEL Functions
+
+CEL string functions can transform values:
+
+```yaml
+optionValues:
+ # Convert to uppercase
+ - name: clusterLabel
+ expression: ${global.greenhouse.clusterName.upperAscii()}
+
+ # Split and rejoin with different delimiter
+ - name: normalizedName
+ expression: ${global.greenhouse.clusterName.split('-').join('_')}
+
+ # Check if environment contains a substring
+ - name: isProduction
+ expression: ${global.greenhouse.metadata.environment.contains('prod')}
+```
+
+For a complete list of available CEL string functions, see the [CEL string extension documentation](https://github.com/google/cel-go/tree/master/ext#strings).
+
## Next Steps
- [Setting Metadata Labels](./../../../reference/components/cluster#setting-metadata-labels)
diff --git a/e2e/plugin/e2e_test.go b/e2e/plugin/e2e_test.go
index 4aa108615..cc32f522f 100644
--- a/e2e/plugin/e2e_test.go
+++ b/e2e/plugin/e2e_test.go
@@ -7,7 +7,6 @@ package plugin
import (
"context"
- "encoding/base64"
"testing"
"time"
@@ -19,7 +18,6 @@ import (
greenhouseapis "github.com/cloudoperators/greenhouse/api"
greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
- "github.com/cloudoperators/greenhouse/e2e/cluster/expect"
"github.com/cloudoperators/greenhouse/e2e/plugin/scenarios"
"github.com/cloudoperators/greenhouse/e2e/shared"
"github.com/cloudoperators/greenhouse/internal/clientutil"
@@ -27,9 +25,7 @@ import (
)
const (
- remoteClusterName = "remote-plugin-cluster"
- remoteIntegrationCluster = "remote-integration-cluster"
- remoteOIDCClusterRoleBindingName = "oidc-plugin-cluster-role-binding"
+ remoteClusterName = "remote-plugin-cluster"
)
var (
@@ -66,7 +62,6 @@ var _ = BeforeSuite(func() {
var _ = AfterSuite(func() {
shared.OffBoardRemoteCluster(ctx, adminClient, remoteClient, testStartTime, remoteClusterName, env.TestNamespace)
- shared.OffBoardRemoteCluster(ctx, adminClient, remoteClient, testStartTime, remoteIntegrationCluster, env.TestNamespace)
test.EventuallyDeleted(ctx, adminClient, team)
env.GenerateGreenhouseControllerLogs(ctx, testStartTime)
env.GenerateFluxControllerLogs(ctx, "helm-controller", testStartTime)
@@ -108,31 +103,6 @@ var _ = Describe("Plugin E2E", Ordered, func() {
scenarios.FluxControllerPluginDeletionLifecycle(ctx, adminClient, env, remoteClusterName, team.Name)
})
- It("should resolve option values from direct plugin reference", func() {
- By("setting up cluster role binding for OIDC on remote cluster")
- expect.SetupOIDCClusterRoleBinding(ctx, remoteClient, remoteOIDCClusterRoleBindingName, remoteIntegrationCluster, env.TestNamespace)
-
- By("onboarding remote cluster")
- restClient := clientutil.NewRestClientGetterFromBytes(env.RemoteKubeConfigBytes, env.TestNamespace)
- restConfig, err := restClient.ToRESTConfig()
- Expect(err).NotTo(HaveOccurred(), "there should be no error creating the remote REST config")
- remoteAPIServerURL := restConfig.Host
- remoteCA := make([]byte, base64.StdEncoding.EncodedLen(len(restConfig.CAData)))
- base64.StdEncoding.Encode(remoteCA, restConfig.CAData)
- shared.OnboardRemoteOIDCCluster(ctx, adminClient, remoteCA, remoteAPIServerURL, remoteIntegrationCluster, env.TestNamespace, team.Name)
-
- By("verifying the cluster status is ready")
- shared.ClusterIsReady(ctx, adminClient, remoteIntegrationCluster, env.TestNamespace)
-
- By("executing the plugin integration scenario with direct plugin reference")
- scenarios.PluginIntegrationByDirectReference(ctx, adminClient, remoteClient, env, remoteIntegrationCluster)
- })
-
- It("should resolve option values from plugin reference by label selector", func() {
- By("executing the plugin integration scenario with plugin reference by label selector")
- scenarios.PluginIntegrationBySelector(ctx, adminClient, remoteClient, env, remoteIntegrationCluster)
- })
-
Context("OCI image replication", Ordered, func() {
BeforeAll(func() {
By("configuring mirror registry for OCI image replication tests")
diff --git a/e2e/plugin/scenarios/plugin_integration.go b/e2e/plugin/scenarios/plugin_integration.go
deleted file mode 100644
index d12f03aaf..000000000
--- a/e2e/plugin/scenarios/plugin_integration.go
+++ /dev/null
@@ -1,407 +0,0 @@
-// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors
-// SPDX-License-Identifier: Apache-2.0
-
-package scenarios
-
-import (
- "context"
- "crypto/rand"
- "encoding/json"
- "math/big"
- "slices"
-
- helmv2 "github.com/fluxcd/helm-controller/api/v2"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- appsv1 "k8s.io/api/apps/v1"
- corev1 "k8s.io/api/core/v1"
- apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "sigs.k8s.io/controller-runtime/pkg/client"
- "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
-
- 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"
-)
-
-const (
- multiRefPluginLabelKey = "e2e.greenhouse.sap/multi-ref-plugin"
- selectorRefPluginA = "selector-ref-plugin-a"
- selectorRefPluginB = "selector-ref-plugin-b"
- selectorResolverPluginName = "selector-resolver-plugin"
- directResolverPluginName = "direct-resolver-plugin"
- directReferencePluginName = "direct-reference-plugin"
-)
-
-func randIntn(n int) int {
- v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
- if err != nil {
- panic(err)
- }
- return int(v.Int64())
-}
-
-func generateRandomEnvs(prefix string) (*apiextensionsv1.JSON, error) {
- envVars := []map[string]string{
- {
- "name": prefix + "_LOG_LEVEL",
- "value": randomLogLevel(),
- },
- {
- "name": prefix + "_REGION",
- "value": randomRegion(),
- },
- {
- "name": prefix + "_SESSION_ID",
- "value": randomSessionID(),
- },
- {
- "name": prefix + "_MODE",
- "value": randomMode(),
- },
- }
-
- jsonBytes, err := json.Marshal(envVars)
- if err != nil {
- return nil, err
- }
-
- return &apiextensionsv1.JSON{Raw: jsonBytes}, nil
-}
-
-// randomLogLevel returns a random zap log level
-func randomLogLevel() string {
- logLevels := []string{
- "debug",
- "info",
- "warn",
- "error",
- "dpanic",
- "panic",
- "fatal",
- }
- return logLevels[randIntn(len(logLevels))]
-}
-
-// randomRegion returns a random AWS-like region
-func randomRegion() string {
- regions := []string{
- "eu-central-1",
- "eu-west-1",
- "us-east-1",
- "us-west-2",
- "ap-southeast-1",
- "ap-northeast-1",
- }
- return regions[randIntn(len(regions))]
-}
-
-// randomSessionID generates a random session ID
-func randomSessionID() string {
- const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
- b := make([]byte, 8)
- for i := range b {
- b[i] = charset[randIntn(len(charset))]
- }
- return string(b)
-}
-
-// randomMode returns a random deployment mode
-func randomMode() string {
- modes := []string{
- "staging",
- "production",
- "development",
- "testing",
- }
- return modes[randIntn(len(modes))]
-}
-
-// containsExpectedEnvs checks if all environment variables from rawExtraEnvs are present in the envVars slice
-// The envVars may have additional environment variables that we don't care about
-func containsExpectedEnvs(envVars []corev1.EnvVar, rawExtraEnvs any) bool {
- // Parse rawExtraEnvs into a slice of maps
- rawEnvsBytes, err := json.Marshal(rawExtraEnvs)
- if err != nil {
- return false
- }
- var expectedEnvs []map[string]string
- if err := json.Unmarshal(rawEnvsBytes, &expectedEnvs); err != nil {
- return false
- }
- // Check that each expected env var is present in the container with the correct value
- for _, expectedEnv := range expectedEnvs {
- expectedName := expectedEnv["name"]
- expectedValue := expectedEnv["value"]
- if !slices.ContainsFunc(envVars, func(envVar corev1.EnvVar) bool {
- return envVar.Name == expectedName && envVar.Value == expectedValue
- }) {
- return false
- }
- }
- return true
-}
-
-func PluginIntegrationByDirectReference(ctx context.Context, adminClient, remoteClient client.Client, env *shared.TestEnv, remoteClusterName string) {
- By("creating plugin definition")
- testPluginDefinition := fixtures.PreparePodInfoPluginDefinition("podinfo", 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(), "the plugin definition should be ready")
- }).Should(Succeed())
-
- By("creating reference plugin")
- directRefEnvs, err := generateRandomEnvs("DIRECT")
- Expect(err).ToNot(HaveOccurred(), "there should be no error generating random envs for plugin A")
- pluginDirectRef := test.NewPlugin(
- ctx,
- directReferencePluginName,
- env.TestNamespace,
- )
- _, err = controllerutil.CreateOrPatch(ctx, adminClient, pluginDirectRef, func() error {
- pluginDirectRef.Spec = test.NewPlugin(
- ctx,
- directReferencePluginName,
- env.TestNamespace,
- test.WithPluginDefinition(testPluginDefinition.Name),
- test.WithPluginOptionValue("replicaCount", &apiextensionsv1.JSON{Raw: []byte("1")}),
- test.WithPluginOptionValue("extraEnvs", directRefEnvs),
- test.WithReleaseName(directReferencePluginName+"-release"),
- test.WithReleaseNamespace(directReferencePluginName+"-namespace"),
- test.WithCluster(remoteClusterName),
- ).Spec
- return nil
- })
- Expect(err).ToNot(HaveOccurred(), "there should be no error creating the reference plugin")
-
- By("checking the reference plugin is ready")
- Eventually(func(g Gomega) {
- err = adminClient.Get(ctx, client.ObjectKeyFromObject(pluginDirectRef), pluginDirectRef)
- g.Expect(err).NotTo(HaveOccurred())
- g.Expect(pluginDirectRef.Status.IsReadyTrue()).To(BeTrue(), "the reference plugin should be ready")
- }).Should(Succeed(), "there should be no error in reference plugin readiness")
-
- By("creating resolver plugin")
- resolverPlugin := test.NewPlugin(ctx, directResolverPluginName, env.TestNamespace)
- _, err = controllerutil.CreateOrPatch(ctx, adminClient, resolverPlugin, func() error {
- resolverPlugin.Spec = test.NewPlugin(ctx, directResolverPluginName, env.TestNamespace,
- test.WithPluginDefinition(testPluginDefinition.Name),
- test.WithPluginOptionValue("replicaCount", &apiextensionsv1.JSON{Raw: []byte("1")}),
- test.WithReleaseName(directResolverPluginName+"-release"),
- test.WithReleaseNamespace(directResolverPluginName+"-namespace"),
- test.WithCluster(remoteClusterName),
- test.WithPluginOptionValueFromRef("extraEnvs", &greenhousev1alpha1.ExternalValueSource{
- Name: pluginDirectRef.Name,
- Expression: "object.spec.optionValues.filter(o, o.name == 'extraEnvs')[0].value",
- }),
- ).Spec
- return nil
- })
- Expect(err).ToNot(HaveOccurred(), "there should be no error creating the resolver plugin")
-
- By("checking the resolver plugin is ready")
- Eventually(func(g Gomega) {
- err = adminClient.Get(ctx, client.ObjectKeyFromObject(resolverPlugin), resolverPlugin)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error getting the resolver plugin")
- g.Expect(resolverPlugin.Status.IsReadyTrue()).To(BeTrue(), "the resolver plugin should be ready")
- }).Should(Succeed(), "the resolver plugin should be ready")
-
- By("verifying tracking-id annotation is set on the referenced plugin")
- Eventually(func(g Gomega) {
- g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(pluginDirectRef), pluginDirectRef)).To(Succeed(), "there should be no error getting the reference plugin to check annotations")
- annotations := pluginDirectRef.GetAnnotations()
- g.Expect(annotations).NotTo(BeNil(), "there should be annotations on the plugin A")
- g.Expect(annotations[greenhouseapis.AnnotationKeyPluginTackingID]).To(Equal("Plugin/"+resolverPlugin.Name), "the tracking ID annotation on plugin A should match the resolver plugin name")
- }).Should(Succeed(), "the reference plugin should have the tracking ID annotation set by resolver plugin")
-
- By("verifying the resolver extraEnvs values in flux HelmRelease")
- var extraEnvsFromHR any
- hr := &helmv2.HelmRelease{}
- hr.SetName(resolverPlugin.Name)
- hr.SetNamespace(resolverPlugin.Namespace)
- Eventually(func(g Gomega) {
- err = adminClient.Get(ctx, client.ObjectKeyFromObject(hr), hr)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error getting the HelmRelease for the resolver plugin")
-
- var valuesMap map[string]any
- err = json.Unmarshal(hr.Spec.Values.Raw, &valuesMap)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error unmarshalling the HelmRelease values")
-
- extraEnvsFromHR = valuesMap["extraEnvs"]
- extraEnvsBytes, err := json.Marshal(extraEnvsFromHR)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error marshalling the raw extraEnvs from the HelmRelease values")
- resolverExtraEnvs := &apiextensionsv1.JSON{Raw: extraEnvsBytes}
- g.Expect(resolverExtraEnvs).To(Equal(directRefEnvs), "the extraEnvs in the HelmRelease should match the ones from plugin A")
- }).Should(Succeed(), "the HelmRelease for the resolver plugin should have the expected extraEnvs values")
-
- // TODO: can checking the remote deployment be skipped since we already verified the values in the HelmRelease?
- By("verifying the envs in the remote cluster deployment")
- deployment := &appsv1.Deployment{}
- deployment.SetName(resolverPlugin.Spec.ReleaseName + "-podinfo")
- deployment.SetNamespace(resolverPlugin.Spec.ReleaseNamespace)
- Eventually(func(g Gomega) {
- err = remoteClient.Get(ctx, client.ObjectKeyFromObject(deployment), deployment)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error getting the deployment from the remote cluster")
- envVars := deployment.Spec.Template.Spec.Containers[0].Env
- // Verify that the expected envs from extraEnvsFromHR are present in the deployment
- g.Expect(containsExpectedEnvs(envVars, extraEnvsFromHR)).To(BeTrue(), "the deployment should contain all expected environment variables from rawExtraEnvs")
- }).Should(Succeed(), "the deployment should be present in the remote cluster with expected envs")
-}
-
-func PluginIntegrationBySelector(ctx context.Context, adminClient, remoteClient client.Client, env *shared.TestEnv, remoteClusterName string) {
- By("creating plugin definition")
- testPluginDefinition := fixtures.PreparePodInfoPluginDefinition("podinfo-latest", env.TestNamespace, "6.11.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(), "the plugin definition should be ready")
- }).Should(Succeed())
-
- By("creating reference plugins")
- pluginAEnvs, err := generateRandomEnvs("A")
- Expect(err).ToNot(HaveOccurred(), "there should be no error generating random envs for plugin A")
- pluginA := test.NewPlugin(ctx, selectorRefPluginA, env.TestNamespace)
- _, err = controllerutil.CreateOrPatch(ctx, adminClient, pluginA, func() error {
- pluginA.SetLabels(map[string]string{
- multiRefPluginLabelKey: "true",
- })
- pluginA.Spec = test.NewPlugin(
- ctx,
- selectorRefPluginA,
- env.TestNamespace,
- test.WithPluginDefinition(testPluginDefinition.Name),
- test.WithPluginOptionValue("replicaCount", &apiextensionsv1.JSON{Raw: []byte("1")}),
- test.WithPluginOptionValue("extraEnvs", pluginAEnvs),
- test.WithReleaseName(selectorRefPluginA+"-release"),
- test.WithReleaseNamespace(selectorRefPluginA+"-namespace"),
- test.WithCluster(remoteClusterName),
- ).Spec
- return nil
- })
- Expect(err).ToNot(HaveOccurred(), "there should be no error creating the plugin "+selectorRefPluginA)
- pluginBEnvs, err := generateRandomEnvs("B")
- Expect(err).ToNot(HaveOccurred(), "there should be no error generating random envs for plugin B")
- pluginB := test.NewPlugin(ctx, selectorRefPluginB, env.TestNamespace)
- _, err = controllerutil.CreateOrPatch(ctx, adminClient, pluginB, func() error {
- pluginB.SetLabels(map[string]string{
- multiRefPluginLabelKey: "true",
- })
- pluginB.Spec = test.NewPlugin(
- ctx,
- selectorRefPluginB,
- env.TestNamespace,
- test.WithPluginDefinition(testPluginDefinition.Name),
- test.WithPluginOptionValue("replicaCount", &apiextensionsv1.JSON{Raw: []byte("1")}),
- test.WithPluginOptionValue("extraEnvs", pluginBEnvs),
- test.WithReleaseName(selectorRefPluginB+"-release"),
- test.WithReleaseNamespace(selectorRefPluginB+"-namespace"),
- test.WithCluster(remoteClusterName),
- ).Spec
- return nil
- })
- Expect(err).ToNot(HaveOccurred(), "there should be no error creating the plugin "+selectorRefPluginB)
-
- By("creating resolver plugin with selector reference to the plugins")
- resolverPlugin := test.NewPlugin(ctx, selectorResolverPluginName, env.TestNamespace)
- _, err = controllerutil.CreateOrPatch(ctx, adminClient, resolverPlugin, func() error {
- resolverPlugin.Spec = test.NewPlugin(
- ctx,
- selectorResolverPluginName,
- env.TestNamespace,
- test.WithPluginDefinition(testPluginDefinition.Name),
- test.WithPluginOptionValue("replicaCount", &apiextensionsv1.JSON{Raw: []byte("1")}),
- test.WithReleaseName(selectorResolverPluginName+"-release"),
- test.WithReleaseNamespace(selectorResolverPluginName+"-namespace"),
- test.WithCluster(remoteClusterName),
- test.WithPluginOptionValueFromRef("extraEnvs", &greenhousev1alpha1.ExternalValueSource{
- Selector: &metav1.LabelSelector{
- MatchLabels: map[string]string{
- multiRefPluginLabelKey: "true",
- },
- },
- Expression: "object.spec.optionValues.filter(o, o.name == 'extraEnvs')[0].value",
- }),
- ).Spec
- return nil
- })
- Expect(err).ToNot(HaveOccurred(), "there should be no error creating the resolver plugin with selector reference")
-
- By("checking the resolver plugin is ready")
- Eventually(func(g Gomega) {
- err = adminClient.Get(ctx, client.ObjectKeyFromObject(resolverPlugin), resolverPlugin)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error getting the resolver plugin")
- g.Expect(resolverPlugin.Status.IsReadyTrue()).To(BeTrue(), "the resolver plugin should be ready")
- }).Should(Succeed(), "the resolver plugin should be ready")
-
- // TODO: check tracking ID annotation similar to how the controller does it by exporting the helper functions
- By("checking the reference plugins are ready and have the tracking ID annotation set")
- Eventually(func(g Gomega) {
- pluginList := &greenhousev1alpha1.PluginList{}
- err = adminClient.List(ctx, pluginList, client.InNamespace(env.TestNamespace), client.MatchingLabels{multiRefPluginLabelKey: "true"})
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error listing plugins with the multi-ref label")
- g.Expect(pluginList.Items).To(HaveLen(2), "there should be 2 plugins with the multi-ref label")
- for i := range pluginList.Items {
- g.Expect(pluginList.Items[i].Status.IsReadyTrue()).To(BeTrue(), "each plugin with the multi-ref label should be ready")
- annotations := pluginList.Items[i].GetAnnotations()
- g.Expect(annotations).NotTo(BeNil(), "there should be annotations on each plugin with the multi-ref label")
- g.Expect(annotations[greenhouseapis.AnnotationKeyPluginTackingID]).To(Equal("Plugin/"+resolverPlugin.Name), "the tracking ID annotation should match the resolver plugin name")
- }
- }).Should(Succeed(), "all reference plugins should be ready")
-
- By("verifying the resolver extraEnvs values in flux HelmRelease")
- var extraEnvsFromHR any
- hr := &helmv2.HelmRelease{}
- hr.SetName(resolverPlugin.Name)
- hr.SetNamespace(resolverPlugin.Namespace)
- Eventually(func(g Gomega) {
- err = adminClient.Get(ctx, client.ObjectKeyFromObject(hr), hr)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error getting the HelmRelease for the resolver plugin")
-
- var valuesMap map[string]any
- err = json.Unmarshal(hr.Spec.Values.Raw, &valuesMap)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error unmarshalling the HelmRelease values")
-
- extraEnvsFromHR = valuesMap["extraEnvs"]
-
- // Since the resolver plugin references multiple plugins with a selector, the expected extraEnvs in the HelmRelease should be a combination of the extraEnvs from both plugins
- // We verify that all envs from both plugins are present, regardless of order (since selector results and helm value ordering are non-deterministic)
- var pluginAEnvsSlice, pluginBEnvsSlice []map[string]any
- err = json.Unmarshal(pluginAEnvs.Raw, &pluginAEnvsSlice)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error unmarshalling plugin A envs")
- err = json.Unmarshal(pluginBEnvs.Raw, &pluginBEnvsSlice)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error unmarshalling plugin B envs")
-
- // Verify all envs from both plugins are present in the HelmRelease
- for _, expectedEnv := range pluginAEnvsSlice {
- g.Expect(extraEnvsFromHR).To(ContainElement(expectedEnv), "the HelmRelease should contain environment variable from plugin A: %v", expectedEnv)
- }
- for _, expectedEnv := range pluginBEnvsSlice {
- g.Expect(extraEnvsFromHR).To(ContainElement(expectedEnv), "the HelmRelease should contain environment variable from plugin B: %v", expectedEnv)
- }
- }).Should(Succeed(), "the HelmRelease for the resolver plugin should have the expected combined extraEnvs values")
-
- By("verifying the envs in the remote cluster deployment")
- deployment := &appsv1.Deployment{}
- deployment.SetName(resolverPlugin.Spec.ReleaseName + "-podinfo")
- deployment.SetNamespace(resolverPlugin.Spec.ReleaseNamespace)
- Eventually(func(g Gomega) {
- err = remoteClient.Get(ctx, client.ObjectKeyFromObject(deployment), deployment)
- g.Expect(err).NotTo(HaveOccurred(), "there should be no error getting the deployment from the remote cluster")
- envVars := deployment.Spec.Template.Spec.Containers[0].Env
-
- // Verify that the expected envs from extraEnvsFromHR are present in the deployment
- g.Expect(containsExpectedEnvs(envVars, extraEnvsFromHR)).To(BeTrue(), "the deployment should contain all expected environment variables from the HelmRelease")
- }).Should(Succeed(), "the deployment should be present in the remote cluster with expected envs")
-}
diff --git a/e2e/pluginpreset/e2e_test.go b/e2e/pluginpreset/e2e_test.go
index d4cc6d76f..cd052bd0e 100644
--- a/e2e/pluginpreset/e2e_test.go
+++ b/e2e/pluginpreset/e2e_test.go
@@ -91,4 +91,16 @@ var _ = Describe("PluginPreset E2E", Ordered, func() {
It("should resolve CEL expressions in PluginPreset", func() {
scenarios.PluginPresetExpressionEvaluation(ctx, adminClient, remoteClient, env, remoteClusterName, team.Name)
})
+
+ It("should resolve cross-PluginPreset references by name", func() {
+ scenarios.PluginPresetCrossPresetReference(ctx, adminClient, remoteClient, env, remoteClusterName, team.Name)
+ })
+
+ It("should resolve cross-PluginPreset references by selector", func() {
+ scenarios.PluginPresetSelectorReference(ctx, adminClient, remoteClient, env, remoteClusterName, team.Name)
+ })
+
+ It("should resolve cross-PluginPreset references respecting source ClusterOptionOverrides", func() {
+ scenarios.PluginPresetCrossPresetReferenceWithOverrides(ctx, adminClient, remoteClient, env, remoteClusterName, team.Name)
+ })
})
diff --git a/e2e/pluginpreset/scenarios/constants.go b/e2e/pluginpreset/scenarios/constants.go
index a4416721f..e5a48df0a 100644
--- a/e2e/pluginpreset/scenarios/constants.go
+++ b/e2e/pluginpreset/scenarios/constants.go
@@ -4,7 +4,9 @@
package scenarios
const (
- optionUIMessage = "ui.message"
- optionUIBackend = "ui.backend"
- optionReplicaCount = "replicaCount"
+ optionUIMessage = "ui.message"
+ optionUIBackend = "ui.backend"
+ optionReplicaCount = "replicaCount"
+ optionServiceHostname = "service.hostname"
+ selectorTestLabel = "e2e.greenhouse.sap/selector-ref-test"
)
diff --git a/e2e/pluginpreset/scenarios/cross_preset_reference.go b/e2e/pluginpreset/scenarios/cross_preset_reference.go
new file mode 100644
index 000000000..2d6c8de91
--- /dev/null
+++ b/e2e/pluginpreset/scenarios/cross_preset_reference.go
@@ -0,0 +1,148 @@
+// 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 PluginPresetCrossPresetReference(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-ref-cluster"
+ err = adminClient.Update(ctx, remoteCluster)
+ Expect(err).ToNot(HaveOccurred())
+
+ By("creating source PluginPreset with CEL expression")
+ sourceExpressionStr := `"generated-${global.greenhouse.clusterName}"`
+ sourcePluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: testPluginDefinition.Name,
+ },
+ ReleaseName: "ref-source",
+ ReleaseNamespace: env.TestNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: optionReplicaCount,
+ Value: test.MustReturnJSONFor("1"),
+ },
+ {
+ Name: optionUIMessage,
+ Expression: &sourceExpressionStr,
+ },
+ },
+ }
+
+ sourcePreset := test.NewPluginPreset("ref-source-preset", env.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, teamName),
+ test.WithPresetPluginSpec(sourcePluginSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "test-ref-cluster"},
+ }),
+ )
+ err = adminClient.Create(ctx, sourcePreset)
+ Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred())
+
+ By("waiting for source Plugin to be ready")
+ expectedSourcePluginName := sourcePreset.Name + "-" + remoteClusterName
+ Eventually(func(g Gomega) {
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ err = adminClient.Get(ctx, client.ObjectKey{Name: expectedSourcePluginName, Namespace: env.TestNamespace}, sourcePlugin)
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(sourcePlugin.Status.IsReadyTrue()).To(BeTrue())
+ }).Should(Succeed())
+
+ By("creating consumer PluginPreset that references source")
+ consumerPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: testPluginDefinition.Name,
+ },
+ ReleaseName: "ref-consumer",
+ ReleaseNamespace: env.TestNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: optionReplicaCount,
+ Value: test.MustReturnJSONFor("1"),
+ },
+ {
+ Name: optionUIMessage,
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: sourcePreset.Name,
+ Expression: `${spec.optionValues.filter(v, v.name == 'ui.message')[0].value}`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("ref-consumer-preset", env.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, teamName),
+ test.WithPresetPluginSpec(consumerPluginSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "test-ref-cluster"},
+ }),
+ )
+ err = adminClient.Create(ctx, consumerPreset)
+ Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred())
+
+ By("verifying consumer Plugin has resolved reference value")
+ expectedConsumerPluginName := consumerPreset.Name + "-" + remoteClusterName
+ Eventually(func(g Gomega) {
+ pluginList := &greenhousev1alpha1.PluginList{}
+ err = adminClient.List(ctx, pluginList, client.MatchingLabels{greenhouseapis.LabelKeyPluginPreset: consumerPreset.Name})
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(pluginList.Items).To(HaveLen(1))
+
+ consumerPlugin := &pluginList.Items[0]
+ g.Expect(consumerPlugin.Name).To(Equal(expectedConsumerPluginName))
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == optionUIMessage {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil(), "ValueFrom should be resolved")
+ g.Expect(ov.Value).ToNot(BeNil())
+ g.Expect(string(ov.Value.Raw)).To(Equal(`"generated-` + remoteClusterName + `"`))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed(), "Consumer Plugin should have resolved reference value")
+
+ By("cleaning up")
+ test.EventuallyDeleted(ctx, adminClient, consumerPreset)
+ test.EventuallyDeleted(ctx, adminClient, sourcePreset)
+ test.EventuallyDeleted(ctx, adminClient, testPluginDefinition)
+}
diff --git a/e2e/pluginpreset/scenarios/cross_preset_reference_overrides.go b/e2e/pluginpreset/scenarios/cross_preset_reference_overrides.go
new file mode 100644
index 000000000..01f2c1cea
--- /dev/null
+++ b/e2e/pluginpreset/scenarios/cross_preset_reference_overrides.go
@@ -0,0 +1,183 @@
+// 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"
+)
+
+// PluginPresetCrossPresetReferenceWithOverrides tests that when a consumer PluginPreset
+// references a source PluginPreset, the source's ClusterOptionOverrides are applied
+// before evaluating expressions. This ensures the consumer gets the per-cluster
+// overridden value, not the default.
+func PluginPresetCrossPresetReferenceWithOverrides(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-override-ref-cluster"
+ err = adminClient.Update(ctx, remoteCluster)
+ Expect(err).ToNot(HaveOccurred())
+
+ By("creating source PluginPreset with default expression and cluster override")
+ defaultExpressionStr := `"default-${global.greenhouse.clusterName}.example.com"`
+ overrideExpressionStr := `"overridden-${global.greenhouse.clusterName}.special.com"`
+
+ sourcePluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: testPluginDefinition.Name,
+ },
+ ReleaseName: "override-ref-source",
+ ReleaseNamespace: env.TestNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: optionReplicaCount,
+ Value: test.MustReturnJSONFor("1"),
+ },
+ {
+ Name: optionServiceHostname,
+ Expression: &defaultExpressionStr,
+ },
+ },
+ }
+
+ sourcePreset := test.NewPluginPreset("override-ref-source", env.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, teamName),
+ test.WithPresetPluginSpec(sourcePluginSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "test-override-ref-cluster"},
+ }),
+ )
+ sourcePreset.Spec.ClusterOptionOverrides = []greenhousev1alpha1.ClusterOptionOverride{
+ {
+ ClusterName: remoteClusterName,
+ Overrides: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: optionServiceHostname,
+ Expression: &overrideExpressionStr,
+ },
+ },
+ },
+ }
+ err = adminClient.Create(ctx, sourcePreset)
+ Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred())
+
+ By("verifying source Plugin has the overridden value (not default)")
+ expectedSourcePluginName := sourcePreset.Name + "-" + remoteClusterName
+ Eventually(func(g Gomega) {
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ err = adminClient.Get(ctx, client.ObjectKey{Name: expectedSourcePluginName, Namespace: env.TestNamespace}, sourcePlugin)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ var found bool
+ for _, ov := range sourcePlugin.Spec.OptionValues {
+ if ov.Name == optionServiceHostname {
+ found = true
+ g.Expect(ov.Value).ToNot(BeNil())
+ g.Expect(string(ov.Value.Raw)).To(Equal(`"overridden-`+remoteClusterName+`.special.com"`),
+ "Source Plugin should have the overridden value, not the default")
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed(), "Source Plugin should have overridden expression resolved")
+
+ By("waiting for source Plugin to be ready")
+ Eventually(func(g Gomega) {
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(adminClient.Get(ctx, client.ObjectKey{Name: expectedSourcePluginName, Namespace: env.TestNamespace}, sourcePlugin)).To(Succeed())
+ g.Expect(sourcePlugin.Status.IsReadyTrue()).To(BeTrue())
+ }).Should(Succeed())
+
+ By("creating consumer PluginPreset that references source")
+ consumerPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: testPluginDefinition.Name,
+ },
+ ReleaseName: "override-ref-consumer",
+ ReleaseNamespace: env.TestNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: optionReplicaCount,
+ Value: test.MustReturnJSONFor("1"),
+ },
+ {
+ Name: "upstream.url",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: sourcePreset.Name,
+ Expression: `"https://" + spec.optionValues.filter(v, v.name == 'service.hostname')[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("override-ref-consumer", env.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, teamName),
+ test.WithPresetPluginSpec(consumerPluginSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "test-override-ref-cluster"},
+ }),
+ )
+ err = adminClient.Create(ctx, consumerPreset)
+ Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred())
+
+ By("verifying consumer Plugin gets the overridden value from source (not default)")
+ expectedConsumerPluginName := consumerPreset.Name + "-" + remoteClusterName
+ Eventually(func(g Gomega) {
+ pluginList := &greenhousev1alpha1.PluginList{}
+ err = adminClient.List(ctx, pluginList, client.MatchingLabels{greenhouseapis.LabelKeyPluginPreset: consumerPreset.Name})
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(pluginList.Items).To(HaveLen(1))
+
+ consumerPlugin := &pluginList.Items[0]
+ g.Expect(consumerPlugin.Name).To(Equal(expectedConsumerPluginName))
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "upstream.url" {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil(), "ValueFrom should be resolved")
+ g.Expect(ov.Value).ToNot(BeNil())
+ g.Expect(string(ov.Value.Raw)).To(Equal(`"https://overridden-`+remoteClusterName+`.special.com"`),
+ "Consumer should receive the overridden value from source, not the default")
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed(), "Consumer Plugin should have resolved reference with source's overridden value")
+
+ By("cleaning up")
+ test.EventuallyDeleted(ctx, adminClient, consumerPreset)
+ test.EventuallyDeleted(ctx, adminClient, sourcePreset)
+ test.EventuallyDeleted(ctx, adminClient, testPluginDefinition)
+}
diff --git a/e2e/pluginpreset/scenarios/expression_evaluation.go b/e2e/pluginpreset/scenarios/expression_evaluation.go
index 2abbb8a35..fc595eaca 100644
--- a/e2e/pluginpreset/scenarios/expression_evaluation.go
+++ b/e2e/pluginpreset/scenarios/expression_evaluation.go
@@ -90,11 +90,6 @@ func PluginPresetExpressionEvaluation(ctx context.Context, adminClient, remoteCl
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 {
diff --git a/e2e/pluginpreset/scenarios/selector_reference.go b/e2e/pluginpreset/scenarios/selector_reference.go
new file mode 100644
index 000000000..2496cf983
--- /dev/null
+++ b/e2e/pluginpreset/scenarios/selector_reference.go
@@ -0,0 +1,195 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package scenarios
+
+import (
+ "context"
+ "encoding/json"
+
+ . "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 PluginPresetSelectorReference(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-selector-cluster"
+ err = adminClient.Update(ctx, remoteCluster)
+ Expect(err).ToNot(HaveOccurred())
+
+ By("creating first source PluginPreset with expression")
+ sourceAExpressionStr := `"endpoint-a.${global.greenhouse.clusterName}.example.com"`
+ sourceAPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: testPluginDefinition.Name,
+ },
+ ReleaseName: "selector-src-a",
+ ReleaseNamespace: env.TestNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: optionReplicaCount,
+ Value: test.MustReturnJSONFor("1"),
+ },
+ {
+ Name: optionUIMessage,
+ Expression: &sourceAExpressionStr,
+ },
+ },
+ }
+
+ sourceAPreset := test.NewPluginPreset("selector-source-a", env.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, teamName),
+ test.WithPluginPresetLabel(selectorTestLabel, "true"),
+ test.WithPresetPluginSpec(sourceAPluginSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "test-selector-cluster"},
+ }),
+ )
+ err = adminClient.Create(ctx, sourceAPreset)
+ Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred())
+
+ By("creating second source PluginPreset with expression")
+ sourceBExpressionStr := `"endpoint-b.${global.greenhouse.clusterName}.example.com"`
+ sourceBPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: testPluginDefinition.Name,
+ },
+ ReleaseName: "selector-src-b",
+ ReleaseNamespace: env.TestNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: optionReplicaCount,
+ Value: test.MustReturnJSONFor("1"),
+ },
+ {
+ Name: optionUIMessage,
+ Expression: &sourceBExpressionStr,
+ },
+ },
+ }
+
+ sourceBPreset := test.NewPluginPreset("selector-source-b", env.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, teamName),
+ test.WithPluginPresetLabel(selectorTestLabel, "true"),
+ test.WithPresetPluginSpec(sourceBPluginSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "test-selector-cluster"},
+ }),
+ )
+ err = adminClient.Create(ctx, sourceBPreset)
+ Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred())
+
+ By("waiting for both source Plugins to be ready")
+ Eventually(func(g Gomega) {
+ sourceAPlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(adminClient.Get(ctx, client.ObjectKey{Name: sourceAPreset.Name + "-" + remoteClusterName, Namespace: env.TestNamespace}, sourceAPlugin)).To(Succeed())
+ g.Expect(sourceAPlugin.Status.IsReadyTrue()).To(BeTrue())
+
+ sourceBPlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(adminClient.Get(ctx, client.ObjectKey{Name: sourceBPreset.Name + "-" + remoteClusterName, Namespace: env.TestNamespace}, sourceBPlugin)).To(Succeed())
+ g.Expect(sourceBPlugin.Status.IsReadyTrue()).To(BeTrue())
+ }).Should(Succeed())
+
+ By("creating consumer PluginPreset that references sources by selector")
+ consumerPluginSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: testPluginDefinition.Name,
+ },
+ ReleaseName: "selector-consumer",
+ ReleaseNamespace: env.TestNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: optionReplicaCount,
+ Value: test.MustReturnJSONFor("1"),
+ },
+ {
+ Name: optionUIMessage,
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ selectorTestLabel: "true",
+ },
+ },
+ Expression: `spec.optionValues.filter(v, v.name == 'ui.message')[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("selector-consumer", env.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, teamName),
+ test.WithPresetPluginSpec(consumerPluginSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "test-selector-cluster"},
+ }),
+ )
+ err = adminClient.Create(ctx, consumerPreset)
+ Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred())
+
+ By("verifying consumer Plugin has collected values from both sources")
+ expectedConsumerPluginName := consumerPreset.Name + "-" + remoteClusterName
+ Eventually(func(g Gomega) {
+ pluginList := &greenhousev1alpha1.PluginList{}
+ err = adminClient.List(ctx, pluginList, client.MatchingLabels{greenhouseapis.LabelKeyPluginPreset: consumerPreset.Name})
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(pluginList.Items).To(HaveLen(1))
+
+ consumerPlugin := &pluginList.Items[0]
+ g.Expect(consumerPlugin.Name).To(Equal(expectedConsumerPluginName))
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == optionUIMessage {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil(), "ValueFrom should be resolved")
+ g.Expect(ov.Value).ToNot(BeNil())
+
+ var endpoints []any
+ err := json.Unmarshal(ov.Value.Raw, &endpoints)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(endpoints).To(HaveLen(2))
+ g.Expect(endpoints).To(ContainElement("endpoint-a." + remoteClusterName + ".example.com"))
+ g.Expect(endpoints).To(ContainElement("endpoint-b." + remoteClusterName + ".example.com"))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed(), "Consumer should have collected values from both sources")
+
+ By("cleaning up")
+ test.EventuallyDeleted(ctx, adminClient, consumerPreset)
+ test.EventuallyDeleted(ctx, adminClient, sourceBPreset)
+ test.EventuallyDeleted(ctx, adminClient, sourceAPreset)
+ test.EventuallyDeleted(ctx, adminClient, testPluginDefinition)
+}
diff --git a/internal/controller/plugin/plugin_controller.go b/internal/controller/plugin/plugin_controller.go
index 7696f1054..76f1e5126 100644
--- a/internal/controller/plugin/plugin_controller.go
+++ b/internal/controller/plugin/plugin_controller.go
@@ -37,14 +37,12 @@ import (
// PluginReconciler reconciles a Plugin object.
type PluginReconciler struct {
client.Client
- KubeRuntimeOpts clientutil.RuntimeOptions
- kubeClientOpts []clientutil.KubeClientOption
- ExpressionEvaluationEnabled bool
- IntegrationEnabled bool
- OCIMirroringEnabled bool
- StoragePath string
- HTTPRetry int
- artifactory flux.IArtifactory
+ KubeRuntimeOpts clientutil.RuntimeOptions
+ kubeClientOpts []clientutil.KubeClientOption
+ OCIMirroringEnabled bool
+ StoragePath string
+ HTTPRetry int
+ artifactory flux.IArtifactory
}
//+kubebuilder:rbac:groups=greenhouse.sap,resources=plugindefinitions,verbs=get;list;watch;create;update;patch;delete
diff --git a/internal/controller/plugin/plugin_controller_flux.go b/internal/controller/plugin/plugin_controller_flux.go
index e215dcc44..dde59659c 100644
--- a/internal/controller/plugin/plugin_controller_flux.go
+++ b/internal/controller/plugin/plugin_controller_flux.go
@@ -106,7 +106,7 @@ func (r *PluginReconciler) EnsureFluxCreated(ctx context.Context, plugin *greenh
return ctrl.Result{}, lifecycle.Failed, errors.New("helm chart not found for " + plugin.Spec.PluginDefinitionRef.Kind + "/" + plugin.Spec.PluginDefinitionRef.Name)
}
- optionValues, err := computeReleaseValues(ctx, r.Client, plugin, r.ExpressionEvaluationEnabled, r.IntegrationEnabled)
+ optionValues, err := computeReleaseValues(ctx, r.Client, plugin)
if err != nil {
plugin.SetCondition(greenhousemetav1alpha1.FalseCondition(
greenhousev1alpha1.HelmReleaseCreatedCondition, greenhousev1alpha1.OptionValueResolutionFailedReason, err.Error()))
@@ -441,91 +441,27 @@ func (r *PluginReconciler) fetchReleaseStatus(ctx context.Context,
}
pluginStatus.Version = pluginVersion
pluginStatus.HelmReleaseStatus = releaseStatus
-
- oldChecksum := ""
- newChecksum := ""
- if plugin.Status.HelmReleaseStatus != nil && plugin.Status.HelmReleaseStatus.PluginOptionChecksum != "" {
- oldChecksum = plugin.Status.HelmReleaseStatus.PluginOptionChecksum
- }
- if plugin.Spec.OptionValues != nil {
- newChecksum, err = helm.CalculatePluginOptionChecksum(ctx, r.Client, plugin)
- if err != nil {
- releaseStatus.PluginOptionChecksum = ""
- } else {
- releaseStatus.PluginOptionChecksum = newChecksum
- }
- }
- if oldChecksum != "" {
- r.reconcileTrackingResources(ctx, plugin, oldChecksum, newChecksum)
- }
}
-// computeReleaseValues resolves Expressions and ValueFromRefs in the Plugin's option values
-// and inserts the Greenhouse values
-func computeReleaseValues(ctx context.Context, c client.Client, plugin *greenhousev1alpha1.Plugin, expressionEvaluation, integrationEnabled bool) ([]greenhousev1alpha1.PluginOptionValue, error) {
+// computeReleaseValues returns the Plugin's option values after validation.
+func computeReleaseValues(ctx context.Context, c client.Client, plugin *greenhousev1alpha1.Plugin) ([]greenhousev1alpha1.PluginOptionValue, error) {
optionValues, err := helm.GetPluginOptionValuesForPlugin(ctx, c, plugin)
if err != nil {
return nil, err
}
- trackedObjects := make([]string, 0)
- // initialize CEL resolver
- var celResolver *helm.CELResolver
- if expressionEvaluation {
- celResolver, err = helm.NewCELResolver(optionValues)
- if err != nil {
- return nil, fmt.Errorf("failed to initialize CEL resolver: %w", err)
- }
- }
- for i, v := range optionValues {
+
+ for _, v := range optionValues {
switch {
case v.Value != nil:
// noop, direct values are already set
continue
- case v.Expression != nil:
- if !expressionEvaluation {
- // skip expression evaluation if not enabled
- continue
- }
- resolvedOptionValue, err := celResolver.ResolveExpression(v, expressionEvaluation)
- if err != nil {
- return nil, err
- }
- optionValues[i] = *resolvedOptionValue
-
- case v.ValueFrom != nil && v.ValueFrom.Ref != nil:
- // skip if integration flag is not enabled
- if !integrationEnabled {
- continue
- }
- //TODO: handle external references
- resolvedOptionValue, objectTrackers, err := ResolveValueFromRef(ctx, c, plugin, v)
- if err != nil {
- return nil, err
- }
- trackedObjects = append(trackedObjects, objectTrackers...)
- optionValues[i] = *resolvedOptionValue
-
case v.ValueFrom != nil && v.ValueFrom.Secret != nil:
// noop, secret refs are not resolved here
continue
default:
- return nil, fmt.Errorf("option value %s has no value or valueFrom set", v.Name)
- }
- }
-
- // update tracking information for plugin integrations
- if integrationEnabled {
- // remove tracking annotations from resources that are no longer being tracked
- if err := removeUntrackedObjectAnnotations(ctx, c, plugin, trackedObjects); err != nil {
- // log err, will retry on next reconciliation
- log.FromContext(ctx).Error(err, "failed to remove untracked object annotations", "namespace", plugin.Namespace, "plugin", plugin.Name)
- }
- if len(trackedObjects) > 0 {
- plugin.Status.TrackedObjects = trackedObjects
- } else {
- // clear tracked objects if there are none
- plugin.Status.TrackedObjects = nil
+ return nil, fmt.Errorf("plugin %s/%s: option value %s must have a direct value or secret reference",
+ plugin.Namespace, plugin.Name, v.Name)
}
}
@@ -591,64 +527,6 @@ func addValueReferences(plugin *greenhousev1alpha1.Plugin) []helmv2.ValuesRefere
return valuesFrom
}
-// reconcileTrackingResources triggers reconciliation on resources that are tracking this plugin.
-// When a plugin's option values change (detected by checksum change), this function annotates
-// all resources that reference this plugin to trigger their reconciliation.
-func (r *PluginReconciler) reconcileTrackingResources(ctx context.Context, plugin *greenhousev1alpha1.Plugin, oldChecksum, newChecksum string) {
- if oldChecksum == newChecksum {
- // No changes, skip reconciliation
- return
- }
-
- // Get the list of trackers from plugin annotations
- trackerIDs := getTrackerIDsFromAnnotations(plugin)
- if len(trackerIDs) == 0 {
- return
- }
-
- // Trigger reconciliation for each tracking resource
- for _, trackerID := range trackerIDs {
- if err := r.triggerReconcileForTracker(ctx, plugin, trackerID); err != nil {
- log.FromContext(ctx).Error(err, "failed to trigger reconciliation for tracking resource", "trackerID", trackerID)
- }
- }
-}
-
-// triggerReconcileForTracker triggers reconciliation for a single tracking resource.
-func (r *PluginReconciler) triggerReconcileForTracker(ctx context.Context, plugin *greenhousev1alpha1.Plugin, trackerID string) error {
- // Parse the tracker ID
- kind, name, err := parseTrackingID(trackerID)
- if err != nil {
- log.FromContext(ctx).Error(err, "invalid tracker ID format", "trackerID", trackerID)
- return err
- }
-
- // Skip self-references
- if name == plugin.GetName() {
- return nil
- }
-
- // Build GVK and key for the tracking resource
- gvk := buildGVK(kind)
- key := types.NamespacedName{
- Name: name,
- Namespace: plugin.GetNamespace(),
- }
-
- // Update the resource with reconcile annotation
- err = updateResourceWithAnnotation(ctx, r.Client, gvk, key)
-
- if err != nil {
- log.FromContext(ctx).Error(err, "failed to annotate tracking object with reconcile request",
- "kind", kind,
- "namespace", plugin.GetNamespace(),
- "name", name)
- return err
- }
-
- return nil
-}
-
func getPluginHelmChart(ctx context.Context, c client.Client, pluginDef common.GenericPluginDefinition, namespace string) (*sourcev1.HelmChart, error) {
helmChartResourceName := pluginDef.FluxHelmChartResourceName()
helmChart := &sourcev1.HelmChart{}
diff --git a/internal/controller/plugin/plugin_controller_flux_test.go b/internal/controller/plugin/plugin_controller_flux_test.go
index f99d746ce..a08eb784d 100644
--- a/internal/controller/plugin/plugin_controller_flux_test.go
+++ b/internal/controller/plugin/plugin_controller_flux_test.go
@@ -182,7 +182,7 @@ var _ = Describe("Flux Plugin Controller", Ordered, func() {
Expect(err).ToNot(HaveOccurred(), "the expected HelmRelease values should be valid JSON")
By("computing the Values for a Plugin")
- actualOptionValues, err := computeReleaseValues(test.Ctx, test.K8sClient, testPlugin, false, false)
+ actualOptionValues, err := computeReleaseValues(test.Ctx, test.K8sClient, testPlugin)
Expect(err).ToNot(HaveOccurred(), "there should be no error computing the HelmRelease values for the Plugin")
actualRaw, err := generateHelmValues(test.Ctx, actualOptionValues)
diff --git a/internal/controller/plugin/plugin_integration/plugin_integration_test.go b/internal/controller/plugin/plugin_integration/plugin_integration_test.go
deleted file mode 100644
index 54e944943..000000000
--- a/internal/controller/plugin/plugin_integration/plugin_integration_test.go
+++ /dev/null
@@ -1,191 +0,0 @@
-// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors
-// SPDX-License-Identifier: Apache-2.0
-
-package plugin_integration
-
-import (
- helmv2 "github.com/fluxcd/helm-controller/api/v2"
- fluxmeta "github.com/fluxcd/pkg/apis/meta"
- sourcecontroller "github.com/fluxcd/source-controller/api/v1"
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- corev1 "k8s.io/api/core/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/types"
- "sigs.k8s.io/controller-runtime/pkg/client"
-
- greenhouseapis "github.com/cloudoperators/greenhouse/api"
- greenhousemetav1alpha1 "github.com/cloudoperators/greenhouse/api/meta/v1alpha1"
- greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
- "github.com/cloudoperators/greenhouse/internal/flux"
- "github.com/cloudoperators/greenhouse/internal/test"
-)
-
-const testNamespace = "greenhouse"
-
-var (
- testPluginDefinition *greenhousev1alpha1.ClusterPluginDefinition
- alerts *greenhousev1alpha1.Plugin
- kubeMonitoring *greenhousev1alpha1.Plugin
-)
-
-var _ = Describe("Plugin Integration", Ordered, func() {
- BeforeAll(func() {
- By("creating greenhouse organization")
- org := test.NewOrganization(test.Ctx, testNamespace)
- err := test.K8sClient.Create(test.Ctx, org)
- Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred(), "there should be no error creating the organization")
- ns := &corev1.Namespace{}
- ns.SetName(testNamespace)
- err = test.K8sClient.Create(test.Ctx, ns)
- Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred(), "there should be no error creating the namespace")
-
- testPluginDefinition = test.NewClusterPluginDefinition(
- test.Ctx,
- "test-tracking-plugindefinition",
- test.WithHelmChart(&greenhousev1alpha1.HelmChartReference{
- Name: "dummy",
- Repository: "oci://greenhouse/helm-charts",
- Version: "1.0.0",
- }),
- )
- Expect(test.K8sClient.Create(test.Ctx, testPluginDefinition)).Should(Succeed(), "there should be no error creating the pluginDefinition")
-
- By("mocking HelmChart Ready condition for testPluginDefinition")
- Eventually(func(g Gomega) {
- helmChart := &sourcecontroller.HelmChart{}
- helmChart.SetName(testPluginDefinition.FluxHelmChartResourceName())
- helmChart.SetNamespace(flux.HelmRepositoryDefaultNamespace)
- err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(helmChart), helmChart)
- g.Expect(err).ToNot(HaveOccurred(), "there should be no error getting the HelmChart")
-
- newHelmChart := &sourcecontroller.HelmChart{}
- *newHelmChart = *helmChart
- helmChartReadyCondition := metav1.Condition{
- Type: fluxmeta.ReadyCondition,
- Status: metav1.ConditionTrue,
- LastTransitionTime: metav1.Now(),
- Reason: "Succeeded",
- Message: "Helm chart is ready",
- }
- newHelmChart.Status.Conditions = []metav1.Condition{helmChartReadyCondition}
- g.Expect(test.K8sClient.Status().Patch(test.Ctx, newHelmChart, client.MergeFrom(helmChart))).To(Succeed(), "there should be no error patching HelmChart status")
- }).Should(Succeed(), "HelmChart should be mocked as ready")
-
- By("waiting for ClusterPluginDefinition to have a Ready condition")
- Eventually(func(g Gomega) {
- err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(testPluginDefinition), testPluginDefinition)
- g.Expect(err).ToNot(HaveOccurred(), "there should be no error getting the ClusterPluginDefinition")
- readyCondition := testPluginDefinition.Status.GetConditionByType(greenhousemetav1alpha1.ReadyCondition)
- g.Expect(readyCondition).ToNot(BeNil(), "the ClusterPluginDefinition should have a Ready condition")
- g.Expect(readyCondition.Status).To(Equal(metav1.ConditionTrue), "ClusterPluginDefinition should be Ready")
- }).Should(Succeed(), "the ClusterPluginDefinition should become Ready")
- })
-
- AfterAll(func() {
- By("cleaning up test plugins")
- test.EventuallyDeleted(test.Ctx, test.K8sClient, kubeMonitoring)
- test.EventuallyDeleted(test.Ctx, test.K8sClient, alerts)
- test.EventuallyDeleted(test.Ctx, test.K8sClient, testPluginDefinition)
- })
-
- It("should create HelmReleases and resolve values from external references", func() {
- By("creating alerts plugin with a label")
- alerts = test.NewPlugin(test.Ctx, "alerts", testNamespace,
- test.WithClusterPluginDefinition(testPluginDefinition.Name),
- test.WithCluster(""),
- test.WithReleaseName("release-alerts"),
- test.WithPluginLabel("test-label", "test-value"),
- test.WithPluginOptionValue("trackedOption", test.MustReturnJSONFor("trackedValue")),
- )
- Expect(test.K8sClient.Create(test.Ctx, alerts)).To(Succeed(), "failed to create alerts plugin")
-
- By("creating kube-monitoring plugin that resolves value from alerts")
- kubeMonitoring = test.NewPlugin(test.Ctx, "kube-monitoring", testNamespace,
- test.WithClusterPluginDefinition(testPluginDefinition.Name),
- test.WithCluster(""),
- test.WithReleaseName("release-kube-monitoring"),
- test.WithPluginOptionValueFromRef("resolvedOption", &greenhousev1alpha1.ExternalValueSource{
- Name: "alerts",
- Expression: "object.spec.optionValues[0].value",
- }),
- )
- Expect(test.K8sClient.Create(test.Ctx, kubeMonitoring)).To(Succeed(), "failed to create kube-monitoring plugin")
-
- By("verifying HelmRelease was created for alerts")
- alertsRelease := &helmv2.HelmRelease{}
- Eventually(func(g Gomega) {
- err := test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "alerts", Namespace: testNamespace}, alertsRelease)
- g.Expect(err).ToNot(HaveOccurred(), "HelmRelease for alerts should exist")
- }).Should(Succeed())
-
- By("verifying HelmRelease was created for kube-monitoring")
- kubeMonitoringRelease := &helmv2.HelmRelease{}
- Eventually(func(g Gomega) {
- err := test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "kube-monitoring", Namespace: testNamespace}, kubeMonitoringRelease)
- g.Expect(err).ToNot(HaveOccurred(), "HelmRelease for kube-monitoring should exist")
- }).Should(Succeed())
-
- By("verifying kube-monitoring resolved the value from alerts")
- Eventually(func(g Gomega) {
- err := test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "kube-monitoring", Namespace: testNamespace}, kubeMonitoringRelease)
- g.Expect(err).ToNot(HaveOccurred(), "HelmRelease for kube-monitoring should exist")
-
- // Check that the HelmRelease contains the resolved value from alerts in its inline values
- g.Expect(kubeMonitoringRelease.Spec.Values).ToNot(BeNil(), "HelmRelease should have inline values")
- valuesRaw := string(kubeMonitoringRelease.Spec.Values.Raw)
- g.Expect(valuesRaw).To(ContainSubstring("trackedValue"), "resolved value should contain 'trackedValue' from alerts plugin")
- }).Should(Succeed())
- })
-
- It("should track and untrack plugin dependencies", func() {
- By("verifying tracking annotation was set on alerts")
- Eventually(func(g Gomega) {
- err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(alerts), alerts)
- g.Expect(err).ToNot(HaveOccurred())
- annotations := alerts.GetAnnotations()
- g.Expect(annotations).To(HaveKey(greenhouseapis.AnnotationKeyPluginTackingID))
- g.Expect(annotations[greenhouseapis.AnnotationKeyPluginTackingID]).To(ContainSubstring("Plugin/kube-monitoring"))
- }).Should(Succeed(), "tracking annotation should be set on alerts")
-
- By("verifying trackedObjects in kube-monitoring status")
- Eventually(func(g Gomega) {
- err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(kubeMonitoring), kubeMonitoring)
- g.Expect(err).ToNot(HaveOccurred())
- g.Expect(kubeMonitoring.Status.TrackedObjects).To(ContainElement("Plugin/alerts"))
- }).Should(Succeed(), "trackedObjects should contain alerts")
-
- By("updating kube-monitoring to remove valueFrom and use a plain value")
- Eventually(func(g Gomega) {
- err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(kubeMonitoring), kubeMonitoring)
- g.Expect(err).ToNot(HaveOccurred())
-
- // Remove the valueFrom option and replace with plain value
- kubeMonitoring.Spec.OptionValues = []greenhousev1alpha1.PluginOptionValue{
- {
- Name: "resolvedOption",
- Value: test.MustReturnJSONFor("plainValue"),
- },
- }
- err = test.K8sClient.Update(test.Ctx, kubeMonitoring)
- g.Expect(err).ToNot(HaveOccurred())
- }).Should(Succeed(), "should update kube-monitoring spec")
-
- By("verifying trackedObjects in kube-monitoring status is empty")
- Eventually(func(g Gomega) {
- err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(kubeMonitoring), kubeMonitoring)
- g.Expect(err).ToNot(HaveOccurred())
- g.Expect(kubeMonitoring.Status.TrackedObjects).To(BeEmpty())
- }).Should(Succeed(), "trackedObjects should be empty after removing valueFrom")
-
- By("verifying tracking annotation was removed from alerts")
- Eventually(func(g Gomega) {
- err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(alerts), alerts)
- g.Expect(err).ToNot(HaveOccurred())
- annotations := alerts.GetAnnotations()
- if trackingID, ok := annotations[greenhouseapis.AnnotationKeyPluginTackingID]; ok {
- g.Expect(trackingID).ToNot(ContainSubstring("Plugin/kube-monitoring"))
- }
- }).Should(Succeed(), "tracking annotation should be removed from alerts")
- })
-})
diff --git a/internal/controller/plugin/plugin_integration/suite_test.go b/internal/controller/plugin/plugin_integration/suite_test.go
deleted file mode 100644
index ae9768a64..000000000
--- a/internal/controller/plugin/plugin_integration/suite_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors
-// SPDX-License-Identifier: Apache-2.0
-
-package plugin_integration
-
-import (
- "testing"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- "k8s.io/client-go/rest"
- ctrl "sigs.k8s.io/controller-runtime"
-
- greenhousecluster "github.com/cloudoperators/greenhouse/internal/controller/cluster"
- "github.com/cloudoperators/greenhouse/internal/controller/plugin"
- greenhouseDef "github.com/cloudoperators/greenhouse/internal/controller/plugindefinition"
- "github.com/cloudoperators/greenhouse/internal/test"
- webhookv1alpha1 "github.com/cloudoperators/greenhouse/internal/webhook/v1alpha1"
-)
-
-func TestPluginIntegration(t *testing.T) {
- RegisterFailHandler(Fail)
- RunSpecs(t, "PluginIntegrationSuite")
-}
-
-var _ = BeforeSuite(func() {
- test.RegisterController("plugin", (&plugin.PluginReconciler{
- IntegrationEnabled: true, // Enable integration features for tracking
- }).SetupWithManager)
- test.RegisterWebhook("organizationWebhook", webhookv1alpha1.SetupOrganizationWebhookWithManager)
- test.RegisterController("clusterPluginDefinition", (&greenhouseDef.ClusterPluginDefinitionReconciler{}).SetupWithManager)
- test.RegisterController("cluster", (&greenhousecluster.RemoteClusterReconciler{}).SetupWithManager)
- test.RegisterWebhook("clusterPluginDefinitionWebhook", webhookv1alpha1.SetupClusterPluginDefinitionWebhookWithManager)
- test.RegisterWebhook("pluginWebhook", webhookv1alpha1.SetupPluginWebhookWithManager)
- test.TestBeforeSuite()
-
- // return the test.Cfg, as the in-cluster config is not available
- ctrl.GetConfig = func() (*rest.Config, error) {
- return test.Cfg, nil
- }
-})
-
-var _ = AfterSuite(func() {
- By("tearing down the test environment")
- test.TestAfterSuite()
-})
diff --git a/internal/controller/plugin/plugin_values_resolver.go b/internal/controller/plugin/plugin_values_resolver.go
deleted file mode 100644
index 7248df010..000000000
--- a/internal/controller/plugin/plugin_values_resolver.go
+++ /dev/null
@@ -1,501 +0,0 @@
-// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors
-// SPDX-License-Identifier: Apache-2.0
-
-package plugin
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "reflect"
- "slices"
- "strings"
- "time"
-
- apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
- apierrors "k8s.io/apimachinery/pkg/api/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
- "k8s.io/apimachinery/pkg/labels"
- "k8s.io/apimachinery/pkg/runtime/schema"
- "k8s.io/apimachinery/pkg/types"
- utilerrors "k8s.io/apimachinery/pkg/util/errors"
- "k8s.io/client-go/util/retry"
- "sigs.k8s.io/controller-runtime/pkg/client"
- "sigs.k8s.io/controller-runtime/pkg/log"
-
- greenhouseapis "github.com/cloudoperators/greenhouse/api"
- greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
- "github.com/cloudoperators/greenhouse/pkg/cel"
- "github.com/cloudoperators/greenhouse/pkg/lifecycle"
-)
-
-const (
- // trackingSeparator is used to separate multiple tracking IDs in annotations
- trackingSeparator = ";"
-)
-
-// filterValueRefOptions filters option values to only include those with external references (ValueFrom.Ref).
-func filterValueRefOptions(optionValues []greenhousev1alpha1.PluginOptionValue) []greenhousev1alpha1.PluginOptionValue {
- return slices.DeleteFunc(optionValues, func(o greenhousev1alpha1.PluginOptionValue) bool {
- return o.ValueFrom == nil || o.ValueFrom.Ref == nil
- })
-}
-
-// ResolveValueFromRef resolves a PluginOptionValue which references other Greenhouse resources
-// currently references to Plugin, PluginPreset are supported. The validation is done at CRD level.
-func ResolveValueFromRef(ctx context.Context, c client.Client, plugin *greenhousev1alpha1.Plugin, option greenhousev1alpha1.PluginOptionValue) (*greenhousev1alpha1.PluginOptionValue, []string, error) {
- resolveKind := plugin.GroupVersionKind().Kind
- // current reconciling plugin as trackerID
- tracker := trackingID(resolveKind, plugin.GetName())
- resolvedValue, objectTrackers, err := resolveValueFromRef(ctx, c, plugin, option, resolveKind, tracker)
- if err != nil {
- return nil, nil, err
- }
- return resolvedValue, objectTrackers, nil
-}
-
-// resolveValueFromRef resolves option value from an external reference.
-func resolveValueFromRef(ctx context.Context, c client.Client, plugin *greenhousev1alpha1.Plugin, option greenhousev1alpha1.PluginOptionValue, defaultKind, tracker string) (*greenhousev1alpha1.PluginOptionValue, []string, error) {
- var value any
- var trackedObjects []string
- var err error
- resolveKind := defaultKind
- if option.ValueFrom.Ref.Kind != "" {
- resolveKind = option.ValueFrom.Ref.Kind
- }
- gvk := buildGVK(resolveKind)
- // resolve by name
- if option.ValueFrom.Ref.Name != "" {
- value, err = resolveByName(ctx, c, plugin, option, gvk, tracker)
- if err != nil {
- return nil, nil, err
- }
- trackedObjects = append(trackedObjects, trackingID(resolveKind, option.ValueFrom.Ref.Name))
- }
- // resolve by label selector
- if option.ValueFrom.Ref.Selector != nil {
- var selectorTrackedObjects []string
- value, selectorTrackedObjects, err = resolveBySelector(ctx, c, plugin, option, gvk, tracker)
- if err != nil {
- return nil, nil, err
- }
- trackedObjects = append(trackedObjects, selectorTrackedObjects...)
- }
- if value != nil {
- byteVal, err := json.Marshal(value)
- if err != nil {
- log.FromContext(ctx).Error(err, "failed to marshal resolved value",
- "namespace", plugin.GetNamespace(),
- "name", plugin.GetName(),
- "optionName", option.Name)
- return nil, nil, err
- }
- return &greenhousev1alpha1.PluginOptionValue{
- Name: option.Name,
- Value: &apiextensionsv1.JSON{Raw: byteVal},
- }, trackedObjects, nil
- }
- return nil, trackedObjects, nil
-}
-
-// resolveByName resolves an option value by fetching a specific named resource.
-func resolveByName(ctx context.Context, c client.Client, plugin *greenhousev1alpha1.Plugin, option greenhousev1alpha1.PluginOptionValue, gvk schema.GroupVersionKind, tracker string) (any, error) {
- key := types.NamespacedName{
- Name: option.ValueFrom.Ref.Name,
- Namespace: plugin.GetNamespace(),
- }
- uObject := &unstructured.Unstructured{}
- uObject.SetGroupVersionKind(gvk)
- err := c.Get(ctx, key, uObject)
- if err != nil {
- if apierrors.IsNotFound(err) {
- log.FromContext(ctx).Info("object does not exist, skipping value resolution...",
- "kind", gvk.Kind,
- "namespace", key.Namespace,
- "name", key.Name)
- return nil, nil
- }
- log.FromContext(ctx).Error(err, "failed to get external value",
- "kind", gvk.Kind,
- "name", key.Name,
- "namespace", key.Namespace)
- return nil, err
- }
- value, err := evaluateExpression(ctx, uObject, option.ValueFrom.Ref.Expression)
- if err != nil {
- return nil, err
- }
- // add tracking information
- if err := annotateObjectWithTracking(ctx, c, gvk, key, tracker); err != nil {
- log.FromContext(ctx).Error(err, "failed to annotate external object with tracking info, will retry on next reconciliation",
- "kind", gvk.Kind,
- "namespace", key.Namespace,
- "name", key.Name)
- }
- return value, nil
-}
-
-// resolveBySelector resolves option values by fetching resources matching a label selector.
-func resolveBySelector(ctx context.Context, c client.Client, plugin *greenhousev1alpha1.Plugin, option greenhousev1alpha1.PluginOptionValue, gvk schema.GroupVersionKind, tracker string) (value any, trackedObjects []string, err error) {
- selector, err := metav1.LabelSelectorAsSelector(option.ValueFrom.Ref.Selector)
- if err != nil {
- log.FromContext(ctx).Error(err, "failed to parse label selector",
- "namespace", plugin.GetNamespace(),
- "name", plugin.GetName(),
- "optionName", option.Name)
- return nil, nil, err
- }
-
- value, trackedObjects, err = resolveMany(ctx, c, gvk, selector, plugin.GetNamespace(), option.ValueFrom.Ref.Expression, tracker)
- if err != nil {
- return nil, nil, err
- }
-
- return value, trackedObjects, nil
-}
-
-// evaluateExpression evaluates a CEL expression against a Kubernetes resource.
-func evaluateExpression(ctx context.Context, uObject *unstructured.Unstructured, expression string) (any, error) {
- value, err := cel.Evaluate(expression, uObject)
- if err != nil {
- log.FromContext(ctx).Error(err, "failed to evaluate CEL expression",
- "kind", uObject.GetKind(),
- "namespace", uObject.GetNamespace(),
- "name", uObject.GetName(),
- "expression", expression)
- return nil, err
- }
- return value, nil
-}
-
-// resolveMany fetches multiple Kubernetes resources matching a label selector and evaluates
-// a CEL expression against each.
-func resolveMany(ctx context.Context, c client.Client, gvk schema.GroupVersionKind, selector labels.Selector, namespace, expression, tracker string) (values any, trackedObjects []string, err error) {
- // Fetch all resources matching the selector
- uObjectList := &unstructured.UnstructuredList{}
- uObjectList.SetGroupVersionKind(gvk)
- if err := c.List(
- ctx,
- uObjectList,
- client.InNamespace(namespace),
- client.MatchingLabelsSelector{Selector: selector},
- ); err != nil {
- log.FromContext(ctx).Error(err, "failed to list external values",
- "kind", gvk.Kind,
- "labelSelector", selector)
- return nil, nil, err
- }
- values, trackedObjects, err = processResourceList(ctx, c, uObjectList.Items, gvk, expression, tracker)
- if err != nil {
- return nil, nil, err
- }
- return values, trackedObjects, nil
-}
-
-// processResourceList evaluates a CEL expression against each resource in a list
-// and tracks all processed resources.
-func processResourceList(ctx context.Context, c client.Client, items []unstructured.Unstructured, gvk schema.GroupVersionKind, expression, tracker string) (values []any, trackedObjects []string, err error) {
- for _, item := range items {
- // avoid self-referencing resources to prevent circular dependencies
- self := trackingID(gvk.Kind, item.GetName())
- if self == tracker {
- log.FromContext(ctx).Info("skipping self-referencing resource to avoid circular dependency",
- "kind", gvk.Kind,
- "namespace", item.GetNamespace(),
- "name", item.GetName())
- continue
- }
- value, err := cel.Evaluate(expression, &item)
- if err != nil {
- log.FromContext(ctx).Error(err, "failed to evaluate CEL expression",
- "kind", gvk.Kind,
- "namespace", item.GetNamespace(),
- "name", item.GetName(),
- "expression", expression)
- return nil, nil, fmt.Errorf("failed to evaluate CEL expression from object %s/%s: %w", item.GetNamespace(), item.GetName(), err)
- }
- values = appendToSlice(values, value)
- // track this resource
- trackedObjects = append(trackedObjects, trackingID(gvk.Kind, item.GetName()))
- key := types.NamespacedName{Name: item.GetName(), Namespace: item.GetNamespace()}
- if err := annotateObjectWithTracking(ctx, c, gvk, key, tracker); err != nil {
- log.FromContext(ctx).Error(err, "failed to annotate external object with tracking info, will retry on next reconciliation",
- "kind", gvk.Kind,
- "namespace", item.GetNamespace(),
- "name", item.GetName())
- }
- }
- return values, trackedObjects, nil
-}
-
-// annotateObjectWithTracking adds tracking labels and annotations to a Kubernetes resource.
-// This enables dependency tracking between plugins and the resources they reference.
-func annotateObjectWithTracking(ctx context.Context, c client.Client, gvk schema.GroupVersionKind, key types.NamespacedName, tracker string) error {
- uObject := &unstructured.Unstructured{}
- uObject.SetGroupVersionKind(gvk)
-
- return retry.RetryOnConflict(retry.DefaultRetry, func() error {
- if err := c.Get(ctx, key, uObject); err != nil {
- return err
- }
- addPluginIntegrationLabel(uObject)
- addTrackingAnnotation(uObject, tracker)
- return c.Update(ctx, uObject)
- })
-}
-
-// addPluginIntegrationLabel adds a label to indicate the resource is integrated with a plugin.
-func addPluginIntegrationLabel(uObject *unstructured.Unstructured) {
- oLabels := uObject.GetLabels()
- if oLabels == nil {
- oLabels = make(map[string]string)
- }
-
- if _, ok := oLabels[greenhouseapis.LabelKeyPluginIntegration]; !ok {
- oLabels[greenhouseapis.LabelKeyPluginIntegration] = greenhouseapis.LabelValuePluginIntegration
- uObject.SetLabels(oLabels)
- }
-}
-
-// addTrackingAnnotation adds or updates the tracking annotation with the given tracker ID.
-// tracker IDs are stored as semicolon-separated values.
-func addTrackingAnnotation(uObject *unstructured.Unstructured, tracker string) {
- annotations := uObject.GetAnnotations()
- if annotations == nil {
- annotations = make(map[string]string)
- }
-
- val, ok := annotations[greenhouseapis.AnnotationKeyPluginTackingID]
- if !ok || val == "" {
- // add new tracking annotation
- annotations[greenhouseapis.AnnotationKeyPluginTackingID] = tracker
- uObject.SetAnnotations(annotations)
- } else {
- // existing tracking annotation - append if not already present
- trackers := strings.Split(val, trackingSeparator)
- if !slices.Contains(trackers, tracker) {
- trackers = append(trackers, tracker)
- annotations[greenhouseapis.AnnotationKeyPluginTackingID] = strings.Join(trackers, trackingSeparator)
- uObject.SetAnnotations(annotations)
- }
- }
-}
-
-// removeUntrackedObjectAnnotations removes tracking annotations from resources that are no longer being tracked.
-// This ensures that when a Plugin A changes its value references (e.g., from Plugin B to Plugin C),
-// the tracking annotation is removed from the old resource (Plugin B).
-// It compares the current tracked objects with the previous ones and removes the tracker ID
-// from resources that are no longer in the tracked list.
-func removeUntrackedObjectAnnotations(ctx context.Context, c client.Client, plugin *greenhousev1alpha1.Plugin, currentTrackedObjects []string) error {
- // previously tracked objects from plugin status
- previousTrackedObjects := plugin.Status.TrackedObjects
- if len(previousTrackedObjects) == 0 {
- // No previous tracking, nothing to clean up
- return nil
- }
-
- // find objects previously tracked objects
- untrackedObjects := findUntrackedObjects(previousTrackedObjects, currentTrackedObjects)
- if len(untrackedObjects) == 0 {
- // No untracked objects to clean up
- return nil
- }
-
- // tracker ID for reconciling plugin
- tracker := trackingID(plugin.GroupVersionKind().Kind, plugin.GetName())
-
- // remove tracking-id from each untracked object
- allErrors := make([]error, 0)
- for _, untrackedObjectID := range untrackedObjects {
- if err := removeTrackingAnnotation(ctx, c, plugin.GetNamespace(), untrackedObjectID, tracker); err != nil {
- log.FromContext(ctx).Error(err, "failed to remove tracking annotation from untracked object",
- "plugin", plugin.GetName(),
- "untrackedObject", untrackedObjectID)
- // continue on error the failed attempt can be retried on next reconciliation
- allErrors = append(allErrors, err)
- }
- }
- return utilerrors.NewAggregate(allErrors)
-}
-
-// findUntrackedObjects returns objects that were previously tracked but are not in the current tracked list.
-func findUntrackedObjects(previousTracked, currentTracked []string) []string {
- // create a map of current tracked objects for quick lookup
- currentMap := make(map[string]bool, len(currentTracked))
- for _, obj := range currentTracked {
- currentMap[obj] = true
- }
-
- // find objects that are in previous but not in current
- untrackedObjects := make([]string, 0)
- for _, prevObj := range previousTracked {
- if !currentMap[prevObj] {
- untrackedObjects = append(untrackedObjects, prevObj)
- }
- }
-
- return untrackedObjects
-}
-
-// removeTrackingAnnotation removes a specific tracker ID from a resource's tracking annotation.
-func removeTrackingAnnotation(ctx context.Context, c client.Client, namespace, objectID, tracker string) error {
- kind, name, err := parseTrackingID(objectID)
- if err != nil {
- return err
- }
- gvk := buildGVK(kind)
- key := types.NamespacedName{
- Name: name,
- Namespace: namespace,
- }
-
- return retry.RetryOnConflict(retry.DefaultRetry, func() error {
- uObject := &unstructured.Unstructured{}
- uObject.SetGroupVersionKind(gvk)
-
- if err := c.Get(ctx, key, uObject); err != nil {
- if apierrors.IsNotFound(err) {
- // Resource no longer exists, nothing to clean up
- log.FromContext(ctx).Info("untracked object not found, skipping cleanup",
- "kind", kind,
- "namespace", namespace,
- "name", name)
- return nil
- }
- return err
- }
-
- // current annotations
- annotations := uObject.GetAnnotations()
- if annotations == nil {
- // No annotations, nothing to remove
- return nil
- }
-
- // current tracking annotation value
- trackingValue, ok := annotations[greenhouseapis.AnnotationKeyPluginTackingID]
- if !ok || trackingValue == "" {
- // No tracking annotation, nothing to remove
- return nil
- }
-
- // spread trackers and remove specified one
- trackers := strings.Split(trackingValue, trackingSeparator)
- updatedTrackers := make([]string, 0, len(trackers))
- for _, t := range trackers {
- if t != tracker {
- updatedTrackers = append(updatedTrackers, t)
- }
- }
-
- switch {
- case len(updatedTrackers) == 0:
- // no trackers, remove the annotation entirely
- delete(annotations, greenhouseapis.AnnotationKeyPluginTackingID)
- log.FromContext(ctx).Info("removed tracking annotation from resource",
- "kind", kind,
- "namespace", namespace,
- "name", name,
- "tracker", tracker)
-
- case len(updatedTrackers) < len(trackers):
- // trackers remaining, update the annotation
- annotations[greenhouseapis.AnnotationKeyPluginTackingID] = strings.Join(updatedTrackers, trackingSeparator)
- log.FromContext(ctx).Info("removed tracker from resource",
- "kind", kind,
- "namespace", namespace,
- "name", name,
- "tracker", tracker)
-
- default:
- // no tracker found
- return nil
- }
-
- uObject.SetAnnotations(annotations)
- return c.Update(ctx, uObject)
- })
-}
-
-// buildGVK constructs a GroupVersionKind for Greenhouse resources.
-func buildGVK(kind string) schema.GroupVersionKind {
- return schema.GroupVersionKind{
- Kind: kind,
- Version: greenhousev1alpha1.GroupVersion.Version,
- Group: greenhousev1alpha1.GroupVersion.Group,
- }
-}
-
-// trackingID creates a unique identifier for tracking resource dependencies.
-// The format is "Kind/Name" (e.g., "Plugin/my-plugin").
-func trackingID(kind, name string) string {
- return kind + "/" + name
-}
-
-// parseTrackingID parses a tracking ID string into kind and name components.
-// Returns an error if the format is invalid.
-func parseTrackingID(trackingID string) (kind, name string, err error) {
- parts := strings.Split(trackingID, "/")
- if len(parts) != 2 {
- return "", "", fmt.Errorf("invalid tracking ID format: %s", trackingID)
- }
- return parts[0], parts[1], nil
-}
-
-// getTrackerIDsFromAnnotations extracts tracker IDs from a plugin's tracking annotation.
-// Returns a slice of tracker IDs, or nil if no tracking annotation exists.
-func getTrackerIDsFromAnnotations(plugin *greenhousev1alpha1.Plugin) []string {
- annotations := plugin.GetAnnotations()
- if annotations == nil {
- return nil
- }
-
- trackerIDsStr, ok := annotations[greenhouseapis.AnnotationKeyPluginTackingID]
- if !ok || trackerIDsStr == "" {
- return nil
- }
-
- return strings.Split(trackerIDsStr, trackingSeparator)
-}
-
-// updateResourceWithAnnotation updates a resource with the given annotations using retry logic.
-func updateResourceWithAnnotation(ctx context.Context, c client.Client, gvk schema.GroupVersionKind, key types.NamespacedName) error {
- return retry.RetryOnConflict(retry.DefaultRetry, func() error {
- uObject := &unstructured.Unstructured{}
- uObject.SetGroupVersionKind(gvk)
-
- if err := c.Get(ctx, key, uObject); err != nil {
- return err
- }
-
- annotations := uObject.GetAnnotations()
- if annotations == nil {
- annotations = make(map[string]string)
- }
-
- // Apply the annotation update
- annotations[lifecycle.ReconcileAnnotation] = time.Now().Format(time.DateTime)
-
- uObject.SetAnnotations(annotations)
- return c.Update(ctx, uObject)
- })
-}
-
-// appendToSlice appends a value to a destination slice.
-// CEL expressions can return slices and to avoid nested slices, this function
-// flattens any slice values before appending.
-func appendToSlice(dst []any, v any) []any {
- rv := reflect.ValueOf(v)
- if rv.Kind() == reflect.Slice {
- n := rv.Len()
- // flatten slice values
- for i := range n {
- dst = append(dst, rv.Index(i).Interface())
- }
- } else {
- // Append single value
- dst = append(dst, v)
- }
- return dst
-}
diff --git a/internal/controller/plugin/plugin_values_resolver_test.go b/internal/controller/plugin/plugin_values_resolver_test.go
deleted file mode 100644
index af2545530..000000000
--- a/internal/controller/plugin/plugin_values_resolver_test.go
+++ /dev/null
@@ -1,337 +0,0 @@
-// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors
-// SPDX-License-Identifier: Apache-2.0
-
-package plugin
-
-import (
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/runtime/schema"
-
- greenhouseapis "github.com/cloudoperators/greenhouse/api"
- greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
-)
-
-var _ = Describe("PluginValuesResolver Helper Functions", func() {
- Describe("filterValueRefOptions", func() {
- It("should filter to only include options with ValueFrom.Ref", func() {
- optionValues := []greenhousev1alpha1.PluginOptionValue{
- {
- Name: "plain-value",
- Value: &apiextensionsv1.JSON{Raw: []byte(`"test"`)},
- },
- {
- Name: "ref-value",
- ValueFrom: &greenhousev1alpha1.PluginValueFromSource{
- Ref: &greenhousev1alpha1.ExternalValueSource{
- Name: "other-plugin",
- Expression: "object.spec.optionValues",
- },
- },
- },
- {
- Name: "secret-value",
- ValueFrom: &greenhousev1alpha1.PluginValueFromSource{
- Secret: &greenhousev1alpha1.SecretKeyReference{
- Name: "my-secret",
- Key: "key",
- },
- },
- },
- }
-
- result := filterValueRefOptions(optionValues)
-
- Expect(result).To(HaveLen(1))
- Expect(result[0].Name).To(Equal("ref-value"))
- })
-
- It("should return empty slice when no options have ValueFrom.Ref", func() {
- optionValues := []greenhousev1alpha1.PluginOptionValue{
- {
- Name: "plain-value",
- Value: &apiextensionsv1.JSON{Raw: []byte(`"test"`)},
- },
- }
-
- result := filterValueRefOptions(optionValues)
-
- Expect(result).To(BeEmpty())
- })
- })
-
- Describe("parseTrackingID", func() {
- It("should parse valid tracking ID", func() {
- kind, name, err := parseTrackingID("Plugin/my-plugin")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(kind).To(Equal("Plugin"))
- Expect(name).To(Equal("my-plugin"))
- })
-
- It("should parse tracking ID with special characters", func() {
- kind, name, err := parseTrackingID("PluginPreset/my-preset-123")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(kind).To(Equal("PluginPreset"))
- Expect(name).To(Equal("my-preset-123"))
- })
-
- It("should return error for invalid format without separator", func() {
- _, _, err := parseTrackingID("PluginMyPlugin")
-
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("invalid tracking ID format"))
- })
-
- It("should return error for invalid format with multiple separators", func() {
- _, _, err := parseTrackingID("Plugin/My/Plugin")
-
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("invalid tracking ID format"))
- })
-
- It("should return error for empty string", func() {
- _, _, err := parseTrackingID("")
-
- Expect(err).To(HaveOccurred())
- Expect(err.Error()).To(ContainSubstring("invalid tracking ID format"))
- })
-
- It("should handle tracking ID with only separator", func() {
- kind, name, err := parseTrackingID("/")
-
- Expect(err).ToNot(HaveOccurred())
- Expect(kind).To(Equal(""))
- Expect(name).To(Equal(""))
- })
- })
-
- Describe("getTrackerIDsFromAnnotations", func() {
- It("should extract single tracker ID from plugin annotations", func() {
- plugin := &greenhousev1alpha1.Plugin{
- ObjectMeta: metav1.ObjectMeta{
- Annotations: map[string]string{
- greenhouseapis.AnnotationKeyPluginTackingID: "Plugin/my-plugin",
- },
- },
- }
-
- trackers := getTrackerIDsFromAnnotations(plugin)
-
- Expect(trackers).To(HaveLen(1))
- Expect(trackers[0]).To(Equal("Plugin/my-plugin"))
- })
-
- It("should extract multiple tracker IDs separated by semicolon", func() {
- plugin := &greenhousev1alpha1.Plugin{
- ObjectMeta: metav1.ObjectMeta{
- Annotations: map[string]string{
- greenhouseapis.AnnotationKeyPluginTackingID: "Plugin/plugin-a;Plugin/plugin-b;PluginPreset/preset-c",
- },
- },
- }
-
- trackers := getTrackerIDsFromAnnotations(plugin)
-
- Expect(trackers).To(HaveLen(3))
- Expect(trackers).To(ContainElements("Plugin/plugin-a", "Plugin/plugin-b", "PluginPreset/preset-c"))
- })
-
- It("should return nil when plugin has no annotations", func() {
- plugin := &greenhousev1alpha1.Plugin{
- ObjectMeta: metav1.ObjectMeta{},
- }
-
- trackers := getTrackerIDsFromAnnotations(plugin)
-
- Expect(trackers).To(BeNil())
- })
-
- It("should return nil when tracking annotation is not present", func() {
- plugin := &greenhousev1alpha1.Plugin{
- ObjectMeta: metav1.ObjectMeta{
- Annotations: map[string]string{
- "other-annotation": "value",
- },
- },
- }
-
- trackers := getTrackerIDsFromAnnotations(plugin)
-
- Expect(trackers).To(BeNil())
- })
-
- It("should return nil when tracking annotation is empty", func() {
- plugin := &greenhousev1alpha1.Plugin{
- ObjectMeta: metav1.ObjectMeta{
- Annotations: map[string]string{
- greenhouseapis.AnnotationKeyPluginTackingID: "",
- },
- },
- }
-
- trackers := getTrackerIDsFromAnnotations(plugin)
-
- Expect(trackers).To(BeNil())
- })
- })
-
- Describe("findUntrackedObjects", func() {
- It("should find objects that are no longer tracked", func() {
- previousTracked := []string{"Plugin/plugin-a", "Plugin/plugin-b", "Plugin/plugin-c"}
- currentTracked := []string{"Plugin/plugin-a", "Plugin/plugin-c"}
-
- untracked := findUntrackedObjects(previousTracked, currentTracked)
-
- Expect(untracked).To(HaveLen(1))
- Expect(untracked[0]).To(Equal("Plugin/plugin-b"))
- })
-
- It("should return empty slice when all previous objects are still tracked", func() {
- previousTracked := []string{"Plugin/plugin-a", "Plugin/plugin-b"}
- currentTracked := []string{"Plugin/plugin-a", "Plugin/plugin-b", "Plugin/plugin-c"}
-
- untracked := findUntrackedObjects(previousTracked, currentTracked)
-
- Expect(untracked).To(BeEmpty())
- })
-
- It("should return all previous objects when current is empty", func() {
- previousTracked := []string{"Plugin/plugin-a", "Plugin/plugin-b"}
- var currentTracked []string
-
- untracked := findUntrackedObjects(previousTracked, currentTracked)
-
- Expect(untracked).To(HaveLen(2))
- Expect(untracked).To(ContainElements("Plugin/plugin-a", "Plugin/plugin-b"))
- })
-
- It("should return empty slice when previous is empty", func() {
- var previousTracked []string
- currentTracked := []string{"Plugin/plugin-a"}
-
- untracked := findUntrackedObjects(previousTracked, currentTracked)
-
- Expect(untracked).To(BeEmpty())
- })
-
- It("should handle multiple untracked objects", func() {
- previousTracked := []string{"Plugin/a", "Plugin/b", "Plugin/c", "Plugin/d"}
- currentTracked := []string{"Plugin/a", "Plugin/d"}
-
- untracked := findUntrackedObjects(previousTracked, currentTracked)
-
- Expect(untracked).To(HaveLen(2))
- Expect(untracked).To(ContainElements("Plugin/b", "Plugin/c"))
- })
- })
-
- Describe("appendToSlice", func() {
- It("should append single value to slice", func() {
- dst := []any{"existing"}
- value := "new-value"
-
- result := appendToSlice(dst, value)
-
- Expect(result).To(HaveLen(2))
- Expect(result).To(ContainElements("existing", "new-value"))
- })
-
- It("should flatten and append slice values", func() {
- dst := []any{"existing"}
- value := []string{"value1", "value2", "value3"}
-
- result := appendToSlice(dst, value)
-
- Expect(result).To(HaveLen(4))
- Expect(result).To(ContainElements("existing", "value1", "value2", "value3"))
- })
-
- It("should handle empty destination slice", func() {
- dst := []any{}
- value := "new-value"
-
- result := appendToSlice(dst, value)
-
- Expect(result).To(HaveLen(1))
- Expect(result[0]).To(Equal("new-value"))
- })
-
- It("should handle appending empty slice", func() {
- dst := []any{"existing"}
- var value []string
-
- result := appendToSlice(dst, value)
-
- Expect(result).To(HaveLen(1))
- Expect(result[0]).To(Equal("existing"))
- })
-
- It("should handle different types", func() {
- dst := []any{1, "string"}
- value := []any{true, 3.14}
-
- result := appendToSlice(dst, value)
-
- Expect(result).To(HaveLen(4))
- Expect(result).To(ContainElements(1, "string", true, 3.14))
- })
-
- It("should handle nested slices by flattening only one level", func() {
- dst := []any{"existing"}
- value := []any{[]string{"nested1", "nested2"}, "direct"}
-
- result := appendToSlice(dst, value)
-
- Expect(result).To(HaveLen(3))
- Expect(result[0]).To(Equal("existing"))
- Expect(result[1]).To(Equal([]string{"nested1", "nested2"}))
- Expect(result[2]).To(Equal("direct"))
- })
- })
-
- Describe("Integration: trackingID and parseTrackingID", func() {
- It("should create and parse tracking ID correctly", func() {
- originalKind := "Plugin"
- originalName := "my-plugin"
-
- // Create tracking ID
- id := trackingID(originalKind, originalName)
-
- // Parse it back
- parsedKind, parsedName, err := parseTrackingID(id)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(parsedKind).To(Equal(originalKind))
- Expect(parsedName).To(Equal(originalName))
- })
-
- It("should handle round-trip with special characters", func() {
- originalKind := "PluginPreset"
- originalName := "my-preset-v1.2.3"
-
- id := trackingID(originalKind, originalName)
- parsedKind, parsedName, err := parseTrackingID(id)
-
- Expect(err).ToNot(HaveOccurred())
- Expect(parsedKind).To(Equal(originalKind))
- Expect(parsedName).To(Equal(originalName))
- })
- })
-
- Describe("Integration: buildGVK with schema operations", func() {
- It("should create GVK that can be used for schema operations", func() {
- gvk := buildGVK("Plugin")
-
- // Verify it creates a valid GVK
- Expect(gvk).To(Equal(schema.GroupVersionKind{
- Group: greenhousev1alpha1.GroupVersion.Group,
- Version: greenhousev1alpha1.GroupVersion.Version,
- Kind: "Plugin",
- }))
- })
- })
-})
diff --git a/internal/controller/plugin/pluginpreset_controller.go b/internal/controller/plugin/pluginpreset_controller.go
index ff8164581..e2aac446a 100644
--- a/internal/controller/plugin/pluginpreset_controller.go
+++ b/internal/controller/plugin/pluginpreset_controller.go
@@ -48,6 +48,7 @@ type PluginPresetReconciler struct {
client.Client
recorder events.EventRecorder
ExpressionEvaluationEnabled bool
+ IntegrationEnabled bool
}
//+kubebuilder:rbac:groups=greenhouse.sap,resources=pluginpresets,verbs=get;list;watch;update
diff --git a/internal/controller/plugin/pluginpreset_controller_test.go b/internal/controller/plugin/pluginpreset_controller_test.go
index 3e5cd1ea8..4cdf23723 100644
--- a/internal/controller/plugin/pluginpreset_controller_test.go
+++ b/internal/controller/plugin/pluginpreset_controller_test.go
@@ -4,6 +4,7 @@
package plugin
import (
+ "encoding/json"
"slices"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
@@ -1025,7 +1026,6 @@ var _ = Describe("PluginPreset Controller Lifecycle", Ordered, func() {
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"`))
}
@@ -1097,7 +1097,6 @@ var _ = Describe("PluginPreset Controller Lifecycle", Ordered, func() {
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 + `"`))
}
@@ -1195,6 +1194,51 @@ var _ = Describe("PluginPreset Controller Lifecycle", Ordered, func() {
"error should mention the option name")
})
+ It("should return error when valueFrom.ref is set but IntegrationEnabled is false", func() {
+ reconciler := &PluginPresetReconciler{
+ Client: test.K8sClient,
+ IntegrationEnabled: false,
+ }
+ preset := &greenhousev1alpha1.PluginPreset{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "ref-flag-off-test",
+ Namespace: test.TestNamespace,
+ },
+ Spec: greenhousev1alpha1.PluginPresetSpec{
+ Plugin: greenhousev1alpha1.PluginPresetPluginSpec{
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: "direct.value",
+ Value: test.MustReturnJSONFor("works"),
+ },
+ {
+ Name: "test.reference",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: "source-plugin",
+ Expression: "spec.optionValues[0].value",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ 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 valueFrom.ref exists but flag is disabled")
+ Expect(err.Error()).To(ContainSubstring("integrationEnabled"),
+ "error should mention the flag")
+ Expect(err.Error()).To(ContainSubstring("test.reference"),
+ "error should mention the option name")
+ })
+
It("should succeed when no expressions and flag is disabled", func() {
reconciler := &PluginPresetReconciler{
Client: test.K8sClient,
@@ -1238,6 +1282,837 @@ var _ = Describe("PluginPreset Controller Lifecycle", Ordered, func() {
Expect(result[1].Value).To(Equal(test.MustReturnJSONFor(42)))
})
+ It("`should resolve valueFrom.ref pointing to another PluginPreset with expression`", func() {
+ By("creating source PluginPreset with expression")
+ sourceExpressionStr := `"generated-${global.greenhouse.clusterName}"`
+ srcPluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-ref-src",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: "myRequiredOption",
+ Value: test.MustReturnJSONFor("myValue"),
+ },
+ {
+ Name: "source.value",
+ Expression: &sourceExpressionStr,
+ },
+ },
+ }
+
+ sourcePreset := test.NewPluginPreset("ref-source", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(srcPluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "cluster": clusterA,
+ },
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, sourcePreset)).To(Succeed())
+
+ By("waiting for source Plugin to be created with resolved expression")
+ sourcePluginName := types.NamespacedName{Name: "ref-source-" + clusterA, Namespace: test.TestNamespace}
+ Eventually(func(g Gomega) {
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ err := test.K8sClient.Get(test.Ctx, sourcePluginName, sourcePlugin)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ var found bool
+ for _, ov := range sourcePlugin.Spec.OptionValues {
+ if ov.Name == "source.value" {
+ found = true
+ g.Expect(ov.Value).ToNot(BeNil())
+ g.Expect(string(ov.Value.Raw)).To(Equal(`"generated-` + clusterA + `"`))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed(), "Source Plugin should have resolved expression")
+
+ By("creating consumer PluginPreset that references source")
+ consumerPluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-ref-consumer",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: "myRequiredOption",
+ Value: test.MustReturnJSONFor("myValue"),
+ },
+ {
+ Name: "consumer.value",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: "ref-source",
+ Expression: `${spec.optionValues.filter(v, v.name == "source.value")[0].value}`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("ref-consumer", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerPluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "cluster": clusterA,
+ },
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring consumer Plugin has resolved reference")
+ consumerPluginName := types.NamespacedName{Name: "ref-consumer-" + clusterA, Namespace: test.TestNamespace}
+ Eventually(func(g Gomega) {
+ consumerPlugin := &greenhousev1alpha1.Plugin{}
+ err := test.K8sClient.Get(test.Ctx, consumerPluginName, consumerPlugin)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "consumer.value" {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil(), "ValueFrom should be resolved")
+ g.Expect(ov.Value).ToNot(BeNil())
+ g.Expect(string(ov.Value.Raw)).To(Equal(`"generated-` + clusterA + `"`))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed(), "Consumer Plugin should have resolved reference")
+
+ By("verifying both values match")
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ Expect(test.K8sClient.Get(test.Ctx, sourcePluginName, sourcePlugin)).To(Succeed())
+ consumerPlugin := &greenhousev1alpha1.Plugin{}
+ Expect(test.K8sClient.Get(test.Ctx, consumerPluginName, consumerPlugin)).To(Succeed())
+
+ var sourceVal, consumerVal string
+ for _, ov := range sourcePlugin.Spec.OptionValues {
+ if ov.Name == "source.value" {
+ sourceVal = string(ov.Value.Raw)
+ }
+ }
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "consumer.value" {
+ consumerVal = string(ov.Value.Raw)
+ }
+ }
+ Expect(sourceVal).To(Equal(consumerVal), "Source and consumer values should match")
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, sourcePreset)
+ })
+
+ It("should resolve valueFrom.ref with expression transformation", func() {
+ By("creating source PluginPreset with expression")
+ sourceExpressionStr := `"my-service.${global.greenhouse.clusterName}.example.com"`
+ sourcePluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-transform-src",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: "myRequiredOption",
+ Value: test.MustReturnJSONFor("myValue"),
+ },
+ {
+ Name: "service.hostname",
+ Expression: &sourceExpressionStr,
+ },
+ },
+ }
+
+ sourcePreset := test.NewPluginPreset("ref-transform-source", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(sourcePluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "cluster": clusterA,
+ },
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, sourcePreset)).To(Succeed())
+
+ By("waiting for source Plugin to be created")
+ sourcePluginName := types.NamespacedName{Name: "ref-transform-source-" + clusterA, Namespace: test.TestNamespace}
+ Eventually(func(g Gomega) {
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, sourcePluginName, sourcePlugin)).To(Succeed())
+ }).Should(Succeed())
+
+ By("creating consumer that transforms the referenced value")
+ consumerPluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-transform-consumer",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {
+ Name: "myRequiredOption",
+ Value: test.MustReturnJSONFor("myValue"),
+ },
+ {
+ Name: "consumer.url",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: "ref-transform-source",
+ Expression: `"https://" + spec.optionValues.filter(v, v.name == "service.hostname")[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("ref-transform-consumer", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerPluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "cluster": clusterA,
+ },
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring consumer Plugin has transformed value")
+ consumerPluginName := types.NamespacedName{Name: "ref-transform-consumer-" + clusterA, Namespace: test.TestNamespace}
+ Eventually(func(g Gomega) {
+ consumerPlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, consumerPluginName, consumerPlugin)).To(Succeed())
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "consumer.url" {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil(), "ValueFrom should be resolved")
+ g.Expect(ov.Value).ToNot(BeNil())
+ g.Expect(string(ov.Value.Raw)).To(Equal(
+ `"https://my-service.` + clusterA + `.example.com"`))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed(), "Consumer should have transformed reference value")
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, sourcePreset)
+ })
+
+ It("should resolve valueFrom.ref with selector pointing to multiple PluginPresets", func() {
+ By("creating two source PluginPresets with selector label")
+ sourceAExprStr := `"endpoint-a-${global.greenhouse.clusterName}"`
+ sourceAPluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-sel-a",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {Name: "source.endpoint", Expression: &sourceAExprStr},
+ },
+ }
+
+ sourceAPreset := test.NewPluginPreset("sel-source-a", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPluginPresetLabel("e2e.greenhouse.sap/selector-test", "true"),
+ test.WithPresetPluginSpec(sourceAPluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, sourceAPreset)).To(Succeed())
+
+ sourceBExprStr := `"endpoint-b-${global.greenhouse.clusterName}"`
+ sourceBPluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-sel-b",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {Name: "source.endpoint", Expression: &sourceBExprStr},
+ },
+ }
+
+ sourceBPreset := test.NewPluginPreset("sel-source-b", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPluginPresetLabel("e2e.greenhouse.sap/selector-test", "true"),
+ test.WithPresetPluginSpec(sourceBPluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, sourceBPreset)).To(Succeed())
+
+ By("waiting for source Plugins")
+ Eventually(func(g Gomega) {
+ pluginA := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "sel-source-a-" + clusterA, Namespace: test.TestNamespace}, pluginA)).To(Succeed())
+ pluginB := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "sel-source-b-" + clusterA, Namespace: test.TestNamespace}, pluginB)).To(Succeed())
+ }).Should(Succeed())
+
+ By("creating consumer PluginPreset with selector reference")
+ consumerSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-sel-consumer",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {
+ Name: "consumer.endpoints",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "e2e.greenhouse.sap/selector-test": "true",
+ },
+ },
+ Expression: `spec.optionValues.filter(v, v.name == "source.endpoint")[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("sel-consumer", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring consumer Plugin has collected values from both sources")
+ consumerPluginName := types.NamespacedName{Name: "sel-consumer-" + clusterA, Namespace: test.TestNamespace}
+ Eventually(func(g Gomega) {
+ consumerPlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, consumerPluginName, consumerPlugin)).To(Succeed())
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "consumer.endpoints" {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil())
+ g.Expect(ov.Value).ToNot(BeNil())
+
+ var endpoints []any
+ err := json.Unmarshal(ov.Value.Raw, &endpoints)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(endpoints).To(HaveLen(2))
+ g.Expect(endpoints).To(ContainElement("endpoint-a-" + clusterA))
+ g.Expect(endpoints).To(ContainElement("endpoint-b-" + clusterA))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed(), "Consumer should have collected values from both sources")
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, sourceBPreset)
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, sourceAPreset)
+ })
+
+ It("should return empty array when selector matches no PluginPresets", func() {
+ By("creating consumer PluginPreset with selector that matches nothing")
+ consumerSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-empty-sel",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {
+ Name: "consumer.value",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "non-existent-label": "true",
+ },
+ },
+ Expression: `spec.optionValues[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("sel-empty", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring consumer Plugin is created with empty array")
+ consumerPluginName := types.NamespacedName{Name: "sel-empty-" + clusterA, Namespace: test.TestNamespace}
+ Eventually(func(g Gomega) {
+ consumerPlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, consumerPluginName, consumerPlugin)).To(Succeed())
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "consumer.value" {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil(), "ValueFrom should be resolved")
+ g.Expect(ov.Value).ToNot(BeNil(), "Value should be set")
+ g.Expect(string(ov.Value.Raw)).To(Equal("[]"),
+ "Value should be empty array when no presets match selector")
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed(), "Consumer should handle empty selector result")
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ })
+
+ It("should report error when referenced PluginPreset does not exist", func() {
+ By("creating consumer PluginPreset referencing non-existent source")
+ consumerSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-missing-ref",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {
+ Name: "consumer.value",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: "non-existent-preset",
+ Expression: `spec.optionValues[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("ref-missing", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring PluginPreset reports the error")
+ Eventually(func(g Gomega) {
+ err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(consumerPreset), consumerPreset)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ pluginFailedCondition := consumerPreset.Status.GetConditionByType(greenhousev1alpha1.PluginFailedCondition)
+ g.Expect(pluginFailedCondition).ToNot(BeNil())
+ g.Expect(pluginFailedCondition.Status).To(Equal(metav1.ConditionTrue))
+ g.Expect(pluginFailedCondition.Message).To(ContainSubstring("non-existent-preset"))
+ }).Should(Succeed(), "PluginPreset should report error for missing reference")
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ })
+
+ It("should reject unsupported reference kind", func() {
+ consumerSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-bad-kind",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {
+ Name: "consumer.value",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: "Plugin",
+ Name: "some-plugin",
+ Expression: `spec.optionValues[0].value`,
+ },
+ },
+ },
+ },
+ }
+ consumerPreset := test.NewPluginPreset("ref-bad-kind", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring PluginPreset reports unsupported kind error")
+ Eventually(func(g Gomega) {
+ err := test.K8sClient.Get(test.Ctx, client.ObjectKeyFromObject(consumerPreset), consumerPreset)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ pluginFailedCondition := consumerPreset.Status.GetConditionByType(greenhousev1alpha1.PluginFailedCondition)
+ g.Expect(pluginFailedCondition).ToNot(BeNil())
+ g.Expect(pluginFailedCondition.Status).To(Equal(metav1.ConditionTrue))
+ g.Expect(pluginFailedCondition.Message).To(ContainSubstring("unsupported reference kind"))
+ }).Should(Succeed(), "PluginPreset should report error for unsupported kind")
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ })
+
+ It("should resolve valueFrom.ref returning integer value", func() {
+ By("creating source PluginPreset with integer value")
+ sourcePluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-int-src",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {Name: "source.replicas", Value: test.MustReturnJSONFor(3)},
+ },
+ }
+
+ sourcePreset := test.NewPluginPreset("ref-int-source", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(sourcePluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, sourcePreset)).To(Succeed())
+
+ By("waiting for source Plugin")
+ Eventually(func(g Gomega) {
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "ref-int-source-" + clusterA, Namespace: test.TestNamespace}, sourcePlugin)).To(Succeed())
+ }).Should(Succeed())
+
+ By("creating consumer that references integer value")
+ consumerPluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-int-consumer",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {
+ Name: "consumer.replicas",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: "ref-int-source",
+ Expression: `spec.optionValues.filter(v, v.name == "source.replicas")[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("ref-int-consumer", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerPluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring consumer Plugin has integer value")
+ Eventually(func(g Gomega) {
+ consumerPlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "ref-int-consumer-" + clusterA, Namespace: test.TestNamespace}, consumerPlugin)).To(Succeed())
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "consumer.replicas" {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil())
+ g.Expect(ov.Value).ToNot(BeNil())
+ g.Expect(string(ov.Value.Raw)).To(Equal("3"))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed())
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, sourcePreset)
+ })
+
+ It("should resolve valueFrom.ref returning boolean value", func() {
+ By("creating source PluginPreset with boolean value")
+ sourcePluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-bool-src",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {Name: "source.enabled", Value: test.MustReturnJSONFor(true)},
+ },
+ }
+
+ sourcePreset := test.NewPluginPreset("ref-bool-source", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(sourcePluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, sourcePreset)).To(Succeed())
+
+ By("waiting for source Plugin")
+ Eventually(func(g Gomega) {
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "ref-bool-source-" + clusterA, Namespace: test.TestNamespace}, sourcePlugin)).To(Succeed())
+ }).Should(Succeed())
+
+ By("creating consumer that references boolean value")
+ consumerPluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-bool-consumer",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {
+ Name: "consumer.enabled",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: "ref-bool-source",
+ Expression: `spec.optionValues.filter(v, v.name == "source.enabled")[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("ref-bool-consumer", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerPluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring consumer Plugin has boolean value")
+ Eventually(func(g Gomega) {
+ consumerPlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "ref-bool-consumer-" + clusterA, Namespace: test.TestNamespace}, consumerPlugin)).To(Succeed())
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "consumer.enabled" {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil())
+ g.Expect(ov.Value).ToNot(BeNil())
+ g.Expect(string(ov.Value.Raw)).To(Equal("true"))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed())
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, sourcePreset)
+ })
+
+ It("should resolve valueFrom.ref returning object/map value", func() {
+ By("creating source PluginPreset with map value")
+ sourcePluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-map-src",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {Name: "source.labels", Value: test.MustReturnJSONFor(map[string]string{
+ "app": "myapp",
+ "team": "platform",
+ })},
+ },
+ }
+
+ sourcePreset := test.NewPluginPreset("ref-map-source", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(sourcePluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, sourcePreset)).To(Succeed())
+
+ By("waiting for source Plugin")
+ Eventually(func(g Gomega) {
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "ref-map-source-" + clusterA, Namespace: test.TestNamespace}, sourcePlugin)).To(Succeed())
+ }).Should(Succeed())
+
+ By("creating consumer that references map value")
+ consumerPluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-map-consumer",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {
+ Name: "consumer.labels",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: "ref-map-source",
+ Expression: `spec.optionValues.filter(v, v.name == "source.labels")[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("ref-map-consumer", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerPluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring consumer Plugin has map value")
+ Eventually(func(g Gomega) {
+ consumerPlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "ref-map-consumer-" + clusterA, Namespace: test.TestNamespace}, consumerPlugin)).To(Succeed())
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "consumer.labels" {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil())
+ g.Expect(ov.Value).ToNot(BeNil())
+
+ var labels map[string]any
+ err := json.Unmarshal(ov.Value.Raw, &labels)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(labels["app"]).To(Equal("myapp"))
+ g.Expect(labels["team"]).To(Equal("platform"))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed())
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, sourcePreset)
+ })
+
+ It("should resolve valueFrom.ref returning array value", func() {
+ By("creating source PluginPreset with array value")
+ sourcePluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-arr-src",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {Name: "source.hosts", Value: test.MustReturnJSONFor([]string{"host-a.example.com", "host-b.example.com"})},
+ },
+ }
+
+ sourcePreset := test.NewPluginPreset("ref-arr-source", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(sourcePluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, sourcePreset)).To(Succeed())
+
+ By("waiting for source Plugin")
+ Eventually(func(g Gomega) {
+ sourcePlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "ref-arr-source-" + clusterA, Namespace: test.TestNamespace}, sourcePlugin)).To(Succeed())
+ }).Should(Succeed())
+
+ By("creating consumer that references array value")
+ consumerPluginPresetSpec := greenhousev1alpha1.PluginPresetPluginSpec{
+ PluginDefinitionRef: greenhousev1alpha1.PluginDefinitionReference{
+ Kind: greenhousev1alpha1.ClusterPluginDefinitionKind,
+ Name: pluginPresetDefinitionName,
+ },
+ ReleaseName: releaseName + "-arr-consumer",
+ ReleaseNamespace: releaseNamespace,
+ OptionValues: []greenhousev1alpha1.PluginPresetPluginOptionValue{
+ {Name: "myRequiredOption", Value: test.MustReturnJSONFor("myValue")},
+ {
+ Name: "consumer.hosts",
+ ValueFrom: &greenhousev1alpha1.PluginPresetPluginValueFromSource{
+ Ref: &greenhousev1alpha1.ExternalValueSource{
+ Kind: greenhousev1alpha1.PluginPresetKind,
+ Name: "ref-arr-source",
+ Expression: `spec.optionValues.filter(v, v.name == "source.hosts")[0].value`,
+ },
+ },
+ },
+ },
+ }
+
+ consumerPreset := test.NewPluginPreset("ref-arr-consumer", test.TestNamespace,
+ test.WithPluginPresetLabel(greenhouseapis.LabelKeyOwnedBy, testTeam.Name),
+ test.WithPresetPluginSpec(consumerPluginPresetSpec),
+ test.WithPluginPresetClusterSelector(metav1.LabelSelector{
+ MatchLabels: map[string]string{"cluster": clusterA},
+ }))
+ Expect(test.K8sClient.Create(test.Ctx, consumerPreset)).To(Succeed())
+
+ By("ensuring consumer Plugin has array value")
+ Eventually(func(g Gomega) {
+ consumerPlugin := &greenhousev1alpha1.Plugin{}
+ g.Expect(test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: "ref-arr-consumer-" + clusterA, Namespace: test.TestNamespace}, consumerPlugin)).To(Succeed())
+
+ var found bool
+ for _, ov := range consumerPlugin.Spec.OptionValues {
+ if ov.Name == "consumer.hosts" {
+ found = true
+ g.Expect(ov.ValueFrom).To(BeNil())
+ g.Expect(ov.Value).ToNot(BeNil())
+
+ var hosts []any
+ err := json.Unmarshal(ov.Value.Raw, &hosts)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(hosts).To(HaveLen(2))
+ g.Expect(hosts).To(ContainElement("host-a.example.com"))
+ g.Expect(hosts).To(ContainElement("host-b.example.com"))
+ }
+ }
+ g.Expect(found).To(BeTrue())
+ }).Should(Succeed())
+
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, consumerPreset)
+ test.EventuallyDeleted(test.Ctx, test.K8sClient, sourcePreset)
+ })
})
var _ = Describe("applyOverridesToPreset", func() {
diff --git a/internal/controller/plugin/pluginpreset_values_resolver.go b/internal/controller/plugin/pluginpreset_values_resolver.go
index f49b3de8b..cb590aa0d 100644
--- a/internal/controller/plugin/pluginpreset_values_resolver.go
+++ b/internal/controller/plugin/pluginpreset_values_resolver.go
@@ -5,11 +5,19 @@ package plugin
import (
"context"
+ "encoding/json"
+ "errors"
"fmt"
"slices"
+ "strings"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ celgo "github.com/google/cel-go/cel"
greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
"github.com/cloudoperators/greenhouse/internal/helm"
@@ -17,7 +25,7 @@ import (
"github.com/cloudoperators/greenhouse/pkg/cel"
)
-// resolvePluginOptionValuesForPreset resolves expressions in a PluginPreset's
+// resolvePluginOptionValuesForPreset resolves expressions and references in a PluginPreset's
// option values before writing to Plugin.
func (r *PluginPresetReconciler) resolvePluginOptionValuesForPreset(
ctx context.Context,
@@ -25,17 +33,38 @@ func (r *PluginPresetReconciler) resolvePluginOptionValuesForPreset(
cluster *greenhousev1alpha1.Cluster,
) ([]greenhousev1alpha1.PluginOptionValue, error) {
+ var resolvedValues []greenhousev1alpha1.PluginOptionValue
+
if r.ExpressionEvaluationEnabled {
- return r.resolveExpressionsForPreset(ctx, preset, cluster)
+ var err error
+ resolvedValues, err = r.resolveExpressionsForPreset(ctx, preset, cluster)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve expressions: %w", err)
+ }
+ } else {
+ 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)
+ }
+ }
+ resolvedValues = util.ConvertToPluginOptionValues(preset.Spec.Plugin.OptionValues)
}
- 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)
+ if r.IntegrationEnabled {
+ var err error
+ resolvedValues, err = r.resolveReferencesForPreset(ctx, cluster, preset.Namespace, preset.Spec.Plugin.OptionValues, resolvedValues)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve references: %w", err)
+ }
+ } else {
+ for _, ov := range preset.Spec.Plugin.OptionValues {
+ if ov.ValueFrom != nil && ov.ValueFrom.Ref != nil {
+ return nil, fmt.Errorf("option %s has valueFrom.ref but integrationEnabled is disabled for PluginPreset controller", ov.Name)
+ }
}
}
- return util.ConvertToPluginOptionValues(preset.Spec.Plugin.OptionValues), nil
+ return resolvedValues, nil
}
// resolveExpressionsForPreset evaluates all expression fields in PluginPreset option values.
@@ -66,14 +95,18 @@ func (r *PluginPresetReconciler) resolveExpressionsForPreset(
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 {
@@ -98,9 +131,275 @@ func (r *PluginPresetReconciler) resolveExpressionsForPreset(
result = append(result, ov)
}
}
+
return result, nil
}
+// resolveReferencesForPreset resolves all valueFrom.ref fields in option values.
+// It reads refs from the original preset values (PluginPresetPluginOptionValue)
+// and outputs resolved PluginOptionValues.
+func (r *PluginPresetReconciler) resolveReferencesForPreset(
+ ctx context.Context,
+ cluster *greenhousev1alpha1.Cluster,
+ namespace string,
+ presetOptionValues []greenhousev1alpha1.PluginPresetPluginOptionValue,
+ resolvedValues []greenhousev1alpha1.PluginOptionValue,
+) ([]greenhousev1alpha1.PluginOptionValue, error) {
+
+ hasRefs := false
+ for _, ov := range presetOptionValues {
+ if ov.ValueFrom != nil && ov.ValueFrom.Ref != nil {
+ hasRefs = true
+ break
+ }
+ }
+ if !hasRefs {
+ return resolvedValues, nil
+ }
+
+ log := ctrl.LoggerFrom(ctx)
+ result := make([]greenhousev1alpha1.PluginOptionValue, 0, len(presetOptionValues))
+
+ for i, presetOV := range presetOptionValues {
+ if presetOV.ValueFrom != nil && presetOV.ValueFrom.Ref != nil {
+ log.Info("Resolving valueFrom.ref",
+ "option", presetOV.Name,
+ "refKind", presetOV.ValueFrom.Ref.Kind,
+ "refName", presetOV.ValueFrom.Ref.Name)
+
+ resolvedValue, err := r.resolveRef(ctx, presetOV.ValueFrom.Ref, cluster, namespace)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve reference for %s: %w", presetOV.Name, err)
+ }
+
+ byteVal, err := json.Marshal(resolvedValue)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal resolved value for %s: %w", presetOV.Name, err)
+ }
+
+ result = append(result, greenhousev1alpha1.PluginOptionValue{
+ Name: presetOV.Name,
+ Value: &apiextensionsv1.JSON{Raw: byteVal},
+ })
+ } else {
+ result = append(result, resolvedValues[i])
+ }
+ }
+
+ return result, nil
+}
+
+// resolveRef resolves a reference to another resource (PluginPreset or Plugin).
+func (r *PluginPresetReconciler) resolveRef(
+ ctx context.Context,
+ ref *greenhousev1alpha1.ExternalValueSource,
+ cluster *greenhousev1alpha1.Cluster,
+ namespace string,
+) (any, error) {
+
+ refKind := ref.Kind
+ if refKind == "" {
+ refKind = greenhousev1alpha1.PluginPresetKind
+ }
+
+ switch refKind {
+ case greenhousev1alpha1.PluginPresetKind:
+ return r.resolvePluginPresetRef(ctx, ref, cluster, namespace)
+ default:
+ return nil, fmt.Errorf("unsupported reference kind: %s", refKind)
+ }
+}
+
+// resolvePluginPresetRef resolves a reference to PluginPreset(s).
+func (r *PluginPresetReconciler) resolvePluginPresetRef(
+ ctx context.Context,
+ ref *greenhousev1alpha1.ExternalValueSource,
+ cluster *greenhousev1alpha1.Cluster,
+ namespace string,
+) (any, error) {
+
+ switch {
+ case ref.Name != "":
+ return r.resolvePluginPresetRefByName(ctx, ref, cluster, namespace)
+ case ref.Selector != nil:
+ return r.resolvePluginPresetRefBySelector(ctx, ref, cluster, namespace)
+ default:
+ return nil, errors.New("either name or selector must be set in valueFrom.ref for PluginPreset")
+ }
+}
+
+// resolvePluginPresetRefByName resolves a reference to a single PluginPreset by name.
+func (r *PluginPresetReconciler) resolvePluginPresetRefByName(
+ ctx context.Context,
+ ref *greenhousev1alpha1.ExternalValueSource,
+ cluster *greenhousev1alpha1.Cluster,
+ namespace string,
+) (any, error) {
+
+ log := ctrl.LoggerFrom(ctx)
+
+ refPreset := &greenhousev1alpha1.PluginPreset{}
+ if err := r.Get(ctx, types.NamespacedName{Name: ref.Name, Namespace: namespace}, refPreset); err != nil {
+ return nil, fmt.Errorf("failed to get PluginPreset %s: %w", ref.Name, err)
+ }
+
+ log.Info("Resolving reference to PluginPreset by name",
+ "name", ref.Name,
+ "expression", ref.Expression)
+
+ resolvedRefValues := r.resolveReferencedPresetValues(ctx, refPreset, cluster)
+ celObject := buildCELObject(refPreset.Name, refPreset.Namespace, resolvedRefValues)
+
+ value, err := evaluateCELWithObject(ref.Expression, celObject)
+ if err != nil {
+ return nil, fmt.Errorf("failed to evaluate reference expression: %w", err)
+ }
+
+ return value, nil
+}
+
+// resolvePluginPresetRefBySelector resolves references to multiple PluginPresets by label selector.
+func (r *PluginPresetReconciler) resolvePluginPresetRefBySelector(
+ ctx context.Context,
+ ref *greenhousev1alpha1.ExternalValueSource,
+ cluster *greenhousev1alpha1.Cluster,
+ namespace string,
+) (any, error) {
+
+ log := ctrl.LoggerFrom(ctx)
+
+ selector, err := metav1.LabelSelectorAsSelector(ref.Selector)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse label selector: %w", err)
+ }
+
+ presetList := &greenhousev1alpha1.PluginPresetList{}
+ if err := r.List(ctx, presetList,
+ client.InNamespace(namespace),
+ client.MatchingLabelsSelector{Selector: selector},
+ ); err != nil {
+ return nil, fmt.Errorf("failed to list PluginPresets by selector: %w", err)
+ }
+
+ if len(presetList.Items) == 0 {
+ log.Info("No PluginPresets found matching selector", "selector", ref.Selector)
+ return []any{}, nil
+ }
+
+ log.Info("Resolving reference to PluginPresets by selector",
+ "selector", ref.Selector,
+ "matchCount", len(presetList.Items),
+ "expression", ref.Expression)
+
+ results := make([]any, 0, len(presetList.Items))
+ for i := range presetList.Items {
+ refPreset := &presetList.Items[i]
+ resolvedRefValues := r.resolveReferencedPresetValues(ctx, refPreset, cluster)
+ celObject := buildCELObject(refPreset.Name, refPreset.Namespace, resolvedRefValues)
+
+ value, err := evaluateCELWithObject(ref.Expression, celObject)
+ if err != nil {
+ return nil, fmt.Errorf("failed to evaluate reference expression for PluginPreset %s: %w", refPreset.Name, err)
+ }
+ results = appendToResults(results, value)
+ }
+
+ return results, nil
+}
+
+// resolveReferencedPresetValues resolves expressions in a referenced PluginPreset
+// if the ExpressionEvaluationEnabled flag is set.
+func (r *PluginPresetReconciler) resolveReferencedPresetValues(
+ ctx context.Context,
+ refPreset *greenhousev1alpha1.PluginPreset,
+ cluster *greenhousev1alpha1.Cluster,
+) []greenhousev1alpha1.PluginOptionValue {
+
+ if !r.ExpressionEvaluationEnabled {
+ return util.ConvertToPluginOptionValues(refPreset.Spec.Plugin.OptionValues)
+ }
+
+ // Apply cluster-specific overrides to referenced preset first
+ refPresetWithOverrides := applyOverridesToPreset(refPreset, cluster.Name)
+
+ resolvedRefValues, err := r.resolveExpressionsForPreset(ctx, refPresetWithOverrides, cluster)
+ if err != nil {
+ log := ctrl.LoggerFrom(ctx)
+ log.Error(err, "Failed to resolve expressions in referenced PluginPreset, using raw values",
+ "name", refPreset.Name)
+ return util.ConvertToPluginOptionValues(refPresetWithOverrides.Spec.Plugin.OptionValues)
+ }
+
+ return resolvedRefValues
+}
+
+// buildCELObject creates a CEL-friendly object structure from option values.
+func buildCELObject(name, namespace string, optionValues []greenhousev1alpha1.PluginOptionValue) map[string]any {
+ celOptionValues := make([]map[string]any, 0, len(optionValues))
+ for _, ov := range optionValues {
+ item := map[string]any{
+ "name": ov.Name,
+ }
+ if ov.Value != nil && len(ov.Value.Raw) > 0 {
+ var val any
+ if err := json.Unmarshal(ov.Value.Raw, &val); err == nil {
+ item["value"] = val
+ }
+ }
+ celOptionValues = append(celOptionValues, item)
+ }
+
+ return map[string]any{
+ "metadata": map[string]any{
+ "name": name,
+ "namespace": namespace,
+ },
+ "spec": map[string]any{
+ "optionValues": celOptionValues,
+ },
+ }
+}
+
+// appendToResults appends a value to results, flattening slices to avoid nested arrays.
+func appendToResults(results []any, value any) []any {
+ switch v := value.(type) {
+ case []any:
+ results = append(results, v...)
+ default:
+ results = append(results, value)
+ }
+ return results
+}
+
+// evaluateCELWithObject evaluates a CEL expression against an object map.
+// Supports multiple syntax styles:
+// - object.spec.optionValues.filter(...) (legacy)
+// - spec.optionValues.filter(...) (new)
+// - ${spec.optionValues.filter(...)} (new with wrapper)
+func evaluateCELWithObject(expression string, object map[string]any) (any, error) {
+ expr := strings.TrimSpace(expression)
+ if strings.HasPrefix(expr, "${") && strings.HasSuffix(expr, "}") {
+ expr = expr[2 : len(expr)-1]
+ }
+
+ env, err := celgo.NewEnv(
+ celgo.Variable("object", celgo.DynType),
+ celgo.Variable("spec", celgo.DynType),
+ celgo.Variable("metadata", celgo.DynType),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to create CEL environment: %w", err)
+ }
+
+ evalData := map[string]any{
+ "object": object,
+ "spec": object["spec"],
+ "metadata": object["metadata"],
+ }
+
+ return cel.EvaluateWithData(expr, env, evalData)
+}
+
// applyOverridesToPreset returns a copy of the preset with cluster-specific overrides merged.
func applyOverridesToPreset(preset *greenhousev1alpha1.PluginPreset, clusterName string) *greenhousev1alpha1.PluginPreset {
presetCopy := preset.DeepCopy()
diff --git a/internal/controller/plugin/suite_test.go b/internal/controller/plugin/suite_test.go
index 76d68095e..91f6d084f 100644
--- a/internal/controller/plugin/suite_test.go
+++ b/internal/controller/plugin/suite_test.go
@@ -41,6 +41,7 @@ var _ = BeforeSuite(func() {
}).SetupWithManager)
test.RegisterController("pluginPreset", (&PluginPresetReconciler{
ExpressionEvaluationEnabled: true,
+ IntegrationEnabled: true,
}).SetupWithManager)
test.RegisterController("pluginDefinition", (&greenhouseDef.PluginDefinitionReconciler{}).SetupWithManager)
test.RegisterController("clusterPluginDefinition", (&greenhouseDef.ClusterPluginDefinitionReconciler{}).SetupWithManager)
diff --git a/internal/features/features.go b/internal/features/features.go
index 3e637a16d..3dd963d8d 100644
--- a/internal/features/features.go
+++ b/internal/features/features.go
@@ -33,13 +33,12 @@ type dexFeatures struct {
}
type pluginFeatures struct {
- ExpressionEvaluationEnabled bool `yaml:"expressionEvaluationEnabled"`
- IntegrationEnabled bool `yaml:"integrationEnabled"`
- OCIMirroringEnabled bool `yaml:"ociMirroringEnabled"`
+ OCIMirroringEnabled bool `yaml:"ociMirroringEnabled"`
}
type pluginPresetFeatures struct {
ExpressionEvaluationEnabled bool `yaml:"expressionEvaluationEnabled"`
+ IntegrationEnabled bool `yaml:"integrationEnabled"`
}
func NewFeatures(ctx context.Context, k8sClient client.Reader, configMapName, namespace string) (*Features, error) {
@@ -128,38 +127,22 @@ func (f *Features) IsPresetExpressionEvaluationEnabled() bool {
return f.pluginPreset.ExpressionEvaluationEnabled
}
-// IsExpressionEvaluationEnabled returns whether plugin option expression evaluation is enabled.
-// Returns false as default.
-func (f *Features) IsExpressionEvaluationEnabled() bool {
- if f == nil {
- return false
- }
-
- if f.plugin != nil {
- return f.plugin.ExpressionEvaluationEnabled
- }
- if err := f.resolvePluginFeatures(); err != nil {
- ctrl.LoggerFrom(context.Background()).Error(err, "failed to resolve plugin features")
- return false
- }
- return f.plugin.ExpressionEvaluationEnabled
-}
-
-// IsIntegrationEnabled returns whether plugin integration is enabled.
+// IsPresetIntegrationEnabled returns whether ValueFrom.Ref resolution
+// is enabled in the PluginPreset controller.
// Returns false as default.
-func (f *Features) IsIntegrationEnabled() bool {
+func (f *Features) IsPresetIntegrationEnabled() bool {
if f == nil {
return false
}
- if f.plugin != nil {
- return f.plugin.IntegrationEnabled
+ if f.pluginPreset != nil {
+ return f.pluginPreset.IntegrationEnabled
}
- if err := f.resolvePluginFeatures(); err != nil {
- ctrl.LoggerFrom(context.Background()).Error(err, "failed to resolve plugin features")
+ if err := f.resolvePluginPresetFeatures(); err != nil {
+ ctrl.LoggerFrom(context.Background()).Error(err, "failed to resolve pluginPreset features")
return false
}
- return f.plugin.IntegrationEnabled
+ return f.pluginPreset.IntegrationEnabled
}
// IsOCIMirroringEnabled returns whether OCI mirroring is enabled.
diff --git a/internal/features/features_test.go b/internal/features/features_test.go
index 6d2ea4d40..8928b0ffa 100644
--- a/internal/features/features_test.go
+++ b/internal/features/features_test.go
@@ -106,87 +106,40 @@ func Test_DexFeatures(t *testing.T) {
}
}
-// Test_PluginFeatures - test plugin expression evaluation feature gate
+// Test_PluginFeatures tests plugin OCI mirroring feature gate.
func Test_PluginFeatures(t *testing.T) {
type testCase struct {
- name string
- configMapData map[string]string
- getError error
- expectedExpressionEvaluation bool
- expectedIntegrationEnabled bool
- expectedOCIMirroringEnabled bool
+ name string
+ configMapData map[string]string
+ getError error
+ expectedOCIMirroringEnabled bool
}
testCases := []testCase{
{
- name: "it should return true when plugin expression evaluation is enabled",
- configMapData: map[string]string{PluginFeatureKey: "expressionEvaluationEnabled: true\n"},
- expectedExpressionEvaluation: true,
- expectedIntegrationEnabled: false,
- expectedOCIMirroringEnabled: false,
- },
- {
- name: "it should return false when plugin expression evaluation is disabled",
- configMapData: map[string]string{PluginFeatureKey: "expressionEvaluationEnabled: false\n"},
- expectedExpressionEvaluation: false,
- expectedIntegrationEnabled: false,
- expectedOCIMirroringEnabled: false,
- },
- {
- name: "it should return true when plugin integration is enabled",
- configMapData: map[string]string{PluginFeatureKey: "integrationEnabled: true\n"},
- expectedExpressionEvaluation: false,
- expectedIntegrationEnabled: true,
- expectedOCIMirroringEnabled: false,
- },
- {
- name: "it should return both values when both are set",
- configMapData: map[string]string{PluginFeatureKey: "expressionEvaluationEnabled: true\nintegrationEnabled: true\n"},
- expectedExpressionEvaluation: true,
- expectedIntegrationEnabled: true,
- expectedOCIMirroringEnabled: false,
- },
- {
- name: "it should return false when plugin key is not found in feature-flags cm",
- configMapData: map[string]string{"someOtherKey": "value\n"},
- expectedExpressionEvaluation: false,
- expectedIntegrationEnabled: false,
- expectedOCIMirroringEnabled: false,
+ name: "it should return false when plugin key is not found in feature-flags cm",
+ configMapData: map[string]string{"someOtherKey": "value\n"},
+ expectedOCIMirroringEnabled: false,
},
{
- name: "it should return false when feature-flags cm is not found",
- getError: apierrors.NewNotFound(schema.GroupResource{}, "configmap not found"),
- expectedExpressionEvaluation: false,
- expectedIntegrationEnabled: false,
- expectedOCIMirroringEnabled: false,
+ name: "it should return false when feature-flags cm is not found",
+ getError: apierrors.NewNotFound(schema.GroupResource{}, "configmap not found"),
+ expectedOCIMirroringEnabled: false,
},
{
- name: "it should return false when flag is malformed in feature-flags cm",
- configMapData: map[string]string{PluginFeatureKey: "expressionEvaluationEnabled:: invalid_yaml"},
- expectedExpressionEvaluation: false,
- expectedIntegrationEnabled: false,
- expectedOCIMirroringEnabled: false,
+ name: "it should return true when ociMirroringEnabled is explicitly true",
+ configMapData: map[string]string{PluginFeatureKey: "ociMirroringEnabled: true\n"},
+ expectedOCIMirroringEnabled: true,
},
{
- name: "it should return true when ociMirroringEnabled is explicitly true",
- configMapData: map[string]string{PluginFeatureKey: "ociMirroringEnabled: true\n"},
- expectedExpressionEvaluation: false,
- expectedIntegrationEnabled: false,
- expectedOCIMirroringEnabled: true,
+ name: "it should return false when ociMirroringEnabled is explicitly false",
+ configMapData: map[string]string{PluginFeatureKey: "ociMirroringEnabled: false\n"},
+ expectedOCIMirroringEnabled: false,
},
{
- name: "it should return false when ociMirroringEnabled is explicitly false",
- configMapData: map[string]string{PluginFeatureKey: "ociMirroringEnabled: false\n"},
- expectedExpressionEvaluation: false,
- expectedIntegrationEnabled: false,
- expectedOCIMirroringEnabled: false,
- },
- {
- name: "it should return false when ociMirroringEnabled key is missing",
- configMapData: map[string]string{PluginFeatureKey: "expressionEvaluationEnabled: false\n"},
- expectedExpressionEvaluation: false,
- expectedIntegrationEnabled: false,
- expectedOCIMirroringEnabled: false,
+ name: "it should return false when ociMirroringEnabled key is missing",
+ configMapData: map[string]string{PluginFeatureKey: "xxx: false\n"},
+ expectedOCIMirroringEnabled: false,
},
}
@@ -218,63 +171,69 @@ func Test_PluginFeatures(t *testing.T) {
assert.NoError(t, client.IgnoreNotFound(err))
assert.Nil(t, featuresInstance, "Expected nil when ConfigMap is missing")
- expressionEvaluationValue := featuresInstance.IsExpressionEvaluationEnabled()
- integrationEnabledValue := featuresInstance.IsIntegrationEnabled()
ociMirroringValue := featuresInstance.IsOCIMirroringEnabled()
- assert.Equal(t, tc.expectedExpressionEvaluation, expressionEvaluationValue)
- assert.Equal(t, tc.expectedIntegrationEnabled, integrationEnabledValue)
assert.Equal(t, tc.expectedOCIMirroringEnabled, ociMirroringValue)
mockK8sClient.AssertExpectations(t)
return
}
+
assert.NoError(t, err)
- expressionEvaluationValue := featuresInstance.IsExpressionEvaluationEnabled()
- integrationEnabledValue := featuresInstance.IsIntegrationEnabled()
ociMirroringValue := featuresInstance.IsOCIMirroringEnabled()
// Assert expected values
- assert.Equal(t, tc.expectedExpressionEvaluation, expressionEvaluationValue)
- assert.Equal(t, tc.expectedIntegrationEnabled, integrationEnabledValue)
assert.Equal(t, tc.expectedOCIMirroringEnabled, ociMirroringValue)
mockK8sClient.AssertExpectations(t)
})
}
}
+// Test_PluginPresetFeatures tests pluginPreset expression evaluation feature gate.
func Test_PluginPresetFeatures(t *testing.T) {
type testCase struct {
name string
configMapData map[string]string
getError error
expectedExpressionEvaluation bool
+ expectedIntegrationEnabled bool
}
testCases := []testCase{
{
- name: "it should return true when pluginPreset expression evaluation is enabled",
+ name: "it should return true when expressionEvaluationEnabled is true",
configMapData: map[string]string{PluginPresetFeatureKey: "expressionEvaluationEnabled: true\n"},
expectedExpressionEvaluation: true,
+ expectedIntegrationEnabled: false,
},
{
- name: "it should return false when pluginPreset expression evaluation is disabled",
+ name: "it should return false when expressionEvaluationEnabled is false",
configMapData: map[string]string{PluginPresetFeatureKey: "expressionEvaluationEnabled: false\n"},
expectedExpressionEvaluation: false,
+ expectedIntegrationEnabled: false,
+ },
+ {
+ name: "it should return both values when both are set",
+ configMapData: map[string]string{PluginPresetFeatureKey: "expressionEvaluationEnabled: true\nintegrationEnabled: true\n"},
+ expectedExpressionEvaluation: true,
+ expectedIntegrationEnabled: true,
},
{
- name: "it should return false when pluginPreset key is not found in feature-flags cm",
+ name: "it should return false when pluginPreset key is not found",
configMapData: map[string]string{"someOtherKey": "value\n"},
expectedExpressionEvaluation: false,
+ expectedIntegrationEnabled: false,
},
{
name: "it should return false when feature-flags cm is not found",
getError: apierrors.NewNotFound(schema.GroupResource{}, "configmap not found"),
expectedExpressionEvaluation: false,
+ expectedIntegrationEnabled: false,
},
{
- name: "it should return false when flag is malformed in feature-flags cm",
- configMapData: map[string]string{PluginPresetFeatureKey: "expressionEvaluationEnabled:: invalid_yaml"},
+ name: "it should return false when flag is malformed",
+ configMapData: map[string]string{PluginPresetFeatureKey: "integrationEnabled:: invalid_yaml"},
expectedExpressionEvaluation: false,
+ expectedIntegrationEnabled: false,
},
}
for _, tc := range testCases {
@@ -299,67 +258,25 @@ func Test_PluginPresetFeatures(t *testing.T) {
}).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)
-
+ assert.Nil(t, featuresInstance)
+ presetExprValue := featuresInstance.IsPresetExpressionEvaluationEnabled()
+ presetIntValue := featuresInstance.IsPresetIntegrationEnabled()
+ assert.Equal(t, tc.expectedExpressionEvaluation, presetExprValue)
+ assert.Equal(t, tc.expectedIntegrationEnabled, presetIntValue)
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")
-
+ presetExprValue := featuresInstance.IsPresetExpressionEvaluationEnabled()
+ presetIntValue := featuresInstance.IsPresetIntegrationEnabled()
+ assert.Equal(t, tc.expectedExpressionEvaluation, presetExprValue)
+ assert.Equal(t, tc.expectedIntegrationEnabled, presetIntValue)
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 b917e927a..270b87f7b 100644
--- a/internal/helm/cel.go
+++ b/internal/helm/cel.go
@@ -4,14 +4,10 @@
package helm
import (
- "encoding/json"
"fmt"
"strings"
- apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
-
greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
- "github.com/cloudoperators/greenhouse/pkg/cel"
)
type CELResolver struct {
@@ -27,38 +23,6 @@ func NewCELResolver(optionValues []greenhousev1alpha1.PluginOptionValue) (*CELRe
return &CELResolver{templateData: templateData}, nil
}
-func (c *CELResolver) ResolveExpression(optionValue greenhousev1alpha1.PluginOptionValue, expressionEvaluationEnabled bool) (*greenhousev1alpha1.PluginOptionValue, error) {
- // early return if there is no expression to resolve
- if optionValue.Expression == nil {
- return &optionValue, nil
- }
- // copy the expression into the value field if expression evaluation is disabled
- if !expressionEvaluationEnabled {
- jsonValue, err := json.Marshal(*optionValue.Expression)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal literal expression for option %s: %w", optionValue.Name, err)
- }
- return &greenhousev1alpha1.PluginOptionValue{
- Name: optionValue.Name,
- Value: &apiextensionsv1.JSON{Raw: jsonValue},
- ValueFrom: nil,
- Expression: nil,
- }, nil
- }
- // evaluate the expression using CEL
- jsonValue, err := cel.EvaluateExpression(*optionValue.Expression, c.templateData)
- if err != nil {
- return nil, fmt.Errorf("failed to evaluate expression for option %s: %w", optionValue.Name, err)
- }
-
- return &greenhousev1alpha1.PluginOptionValue{
- Name: optionValue.Name,
- Value: &apiextensionsv1.JSON{Raw: jsonValue},
- ValueFrom: nil,
- Expression: nil,
- }, nil
-}
-
// 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)
diff --git a/internal/helm/cel_test.go b/internal/helm/cel_test.go
deleted file mode 100644
index ceb489f9f..000000000
--- a/internal/helm/cel_test.go
+++ /dev/null
@@ -1,119 +0,0 @@
-// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Greenhouse contributors
-// SPDX-License-Identifier: Apache-2.0
-
-package helm
-
-import (
- "encoding/json"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
- apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
-
- greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1"
-)
-
-var _ = Describe("ResolveExpressions with feature flag disabled", func() {
- var (
- baseOptionValues []greenhousev1alpha1.PluginOptionValue
- globalClusterName string
- globalRegion string
- globalEnvironment string
- )
-
- BeforeEach(func() {
- globalClusterName = "test-cluster"
- globalRegion = "eu-de-1"
- globalEnvironment = "production"
-
- clusterNameJSON, _ := json.Marshal(globalClusterName)
- regionJSON, _ := json.Marshal(globalRegion)
- envJSON, _ := json.Marshal(globalEnvironment)
-
- baseOptionValues = []greenhousev1alpha1.PluginOptionValue{
- {
- Name: "global.greenhouse.clusterName",
- Value: &apiextensionsv1.JSON{Raw: clusterNameJSON},
- },
- {
- Name: "global.greenhouse.metadata.region",
- Value: &apiextensionsv1.JSON{Raw: regionJSON},
- },
- {
- Name: "global.greenhouse.metadata.environment",
- Value: &apiextensionsv1.JSON{Raw: envJSON},
- },
- }
- })
-
- It("should treat expression as literal string when disabled", func() {
- expression := "prometheus-${global.greenhouse.metadata.region}-user"
- optionValueUT := greenhousev1alpha1.PluginOptionValue{
- Name: "username",
- Expression: &expression,
- }
- optionValues := append(baseOptionValues, optionValueUT)
- resolver, err := NewCELResolver(optionValues)
- Expect(err).ToNot(HaveOccurred())
-
- actual, err := resolver.ResolveExpression(optionValueUT, false)
- Expect(err).ToNot(HaveOccurred())
-
- var username string
- Expect(actual.Value).ToNot(BeNil())
- err = json.Unmarshal(actual.Value.Raw, &username)
- Expect(err).ToNot(HaveOccurred())
-
- Expect(username).To(Equal("prometheus-${global.greenhouse.metadata.region}-user"))
- })
-
- It("should treat multi-line expression as literal string when disabled", func() {
- expression := `endpoint: thanos-grpc.obs.${global.greenhouse.metadata.region}.cloudoperators.dev:443
-cluster: ${global.greenhouse.clusterName}
-env: ${global.greenhouse.metadata.environment}`
-
- optionValueUT := greenhousev1alpha1.PluginOptionValue{
- Name: "config",
- Expression: &expression,
- }
- optionValues := append(baseOptionValues, optionValueUT)
-
- resolver, err := NewCELResolver(optionValues)
- Expect(err).ToNot(HaveOccurred())
-
- actual, err := resolver.ResolveExpression(optionValueUT, false)
- Expect(err).ToNot(HaveOccurred())
-
- var config string
- Expect(actual.Value).ToNot(BeNil())
- err = json.Unmarshal(actual.Value.Raw, &config)
- Expect(err).ToNot(HaveOccurred())
-
- Expect(config).To(Equal(`endpoint: thanos-grpc.obs.${global.greenhouse.metadata.region}.cloudoperators.dev:443
-cluster: ${global.greenhouse.clusterName}
-env: ${global.greenhouse.metadata.environment}`))
- })
-
- It("should treat CEL expression as literal string when disabled", func() {
- expression := "${global.greenhouse.clusterName.upperAscii()}"
-
- optionValueUT := greenhousev1alpha1.PluginOptionValue{
- Name: "clusterLabel",
- Expression: &expression,
- }
- optionValues := append(baseOptionValues, optionValueUT)
-
- resolver, err := NewCELResolver(optionValues)
- Expect(err).ToNot(HaveOccurred())
-
- actual, err := resolver.ResolveExpression(optionValueUT, false)
- Expect(err).ToNot(HaveOccurred())
-
- var clusterLabel string
- Expect(actual.Value).ToNot(BeNil())
- err = json.Unmarshal(actual.Value.Raw, &clusterLabel)
- Expect(err).ToNot(HaveOccurred())
-
- Expect(clusterLabel).To(Equal("${global.greenhouse.clusterName.upperAscii()}"))
- })
-})
diff --git a/internal/helm/helm.go b/internal/helm/helm.go
index 2d28c10dd..1f015f80b 100644
--- a/internal/helm/helm.go
+++ b/internal/helm/helm.go
@@ -405,14 +405,6 @@ func CalculatePluginOptionChecksum(ctx context.Context, c client.Client, plugin
case v.Value != nil:
buf = append(buf, v.Value.Raw...)
- case v.Expression != nil:
- buf = append(buf, []byte(*v.Expression)...)
-
- case v.ValueFrom != nil && v.ValueFrom.Ref != nil:
- buf = append(buf, []byte(v.ValueFrom.Ref.Name)...)
- buf = append(buf, []byte(v.ValueFrom.Ref.Kind)...)
- buf = append(buf, []byte(v.ValueFrom.Ref.Expression)...)
-
default:
continue
}
diff --git a/internal/test/resources.go b/internal/test/resources.go
index 29f7ed3cd..b060c2b44 100644
--- a/internal/test/resources.go
+++ b/internal/test/resources.go
@@ -338,16 +338,14 @@ func WithPluginOptionValue(name string, value *apiextensionsv1.JSON) func(*green
if v.Name == name {
v.Value = value
v.ValueFrom = nil
- v.Expression = nil
p.Spec.OptionValues[i] = v
return
}
}
p.Spec.OptionValues = append(p.Spec.OptionValues, greenhousev1alpha1.PluginOptionValue{
- Name: name,
- Value: value,
- ValueFrom: nil,
- Expression: nil,
+ Name: name,
+ Value: value,
+ ValueFrom: nil,
})
}
}
@@ -361,7 +359,6 @@ func WithPluginOptionValueFrom(name string, valueFrom *greenhousev1alpha1.Plugin
v.ValueFrom = &greenhousev1alpha1.PluginValueFromSource{
Secret: valueFrom.Secret,
}
- v.Expression = nil
p.Spec.OptionValues[i] = v
return
}
@@ -372,53 +369,6 @@ func WithPluginOptionValueFrom(name string, valueFrom *greenhousev1alpha1.Plugin
ValueFrom: &greenhousev1alpha1.PluginValueFromSource{
Secret: valueFrom.Secret,
},
- Expression: nil,
- })
- }
-}
-
-// WithPluginOptionValueFromRef sets the value of a PluginOptionValue from an external reference, clears Value and Expression
-func WithPluginOptionValueFromRef(name string, ref *greenhousev1alpha1.ExternalValueSource) func(*greenhousev1alpha1.Plugin) {
- return func(p *greenhousev1alpha1.Plugin) {
- for i, v := range p.Spec.OptionValues {
- if v.Name == name {
- v.Value = nil
- v.ValueFrom = &greenhousev1alpha1.PluginValueFromSource{
- Ref: ref,
- }
- v.Expression = nil
- p.Spec.OptionValues[i] = v
- return
- }
- }
- p.Spec.OptionValues = append(p.Spec.OptionValues, greenhousev1alpha1.PluginOptionValue{
- Name: name,
- Value: nil,
- ValueFrom: &greenhousev1alpha1.PluginValueFromSource{
- Ref: ref,
- },
- Expression: nil,
- })
- }
-}
-
-// WithPluginOptionValueExpression sets the expression of a PluginOptionValue,
-func WithPluginOptionValueExpression(name string, expression *string) func(*greenhousev1alpha1.Plugin) {
- return func(p *greenhousev1alpha1.Plugin) {
- for i, v := range p.Spec.OptionValues {
- if v.Name == name {
- v.Value = nil
- v.Expression = expression
- v.ValueFrom = nil
- p.Spec.OptionValues[i] = v
- return
- }
- }
- p.Spec.OptionValues = append(p.Spec.OptionValues, greenhousev1alpha1.PluginOptionValue{
- Name: name,
- Value: nil,
- Expression: expression,
- ValueFrom: nil,
})
}
}
diff --git a/internal/webhook/v1alpha1/plugin_webhook.go b/internal/webhook/v1alpha1/plugin_webhook.go
index 24d3d6e07..70fdb5835 100644
--- a/internal/webhook/v1alpha1/plugin_webhook.go
+++ b/internal/webhook/v1alpha1/plugin_webhook.go
@@ -296,11 +296,11 @@ func validatePluginOptionValues(
isOptionValueSet = true
fieldPathWithIndex := optionsFieldPath.Index(idx)
- // Value, ValueFrom, and Expression are mutually exclusive, but exactly one must be provided.
+ // Value, ValueFrom are mutually exclusive, but exactly one must be provided.
if !hasExactlyOneValueSource(val) {
allErrs = append(allErrs, field.Required(
fieldPathWithIndex,
- "must provide exactly one of value, valueFrom, or expression for value "+val.Name,
+ "must provide exactly one of value or valueFrom for value "+val.Name,
))
continue
}
@@ -319,6 +319,12 @@ func validatePluginOptionValues(
}
continue
case val.ValueFrom != nil:
+ if val.ValueFrom.Secret == nil {
+ allErrs = append(allErrs, field.Required(
+ fieldPathWithIndex.Child("valueFrom").Child("secret"),
+ fmt.Sprintf("optionValue %s has valueFrom but no secret reference", val.Name)))
+ continue
+ }
if val.ValueFrom.Secret.Name == "" {
allErrs = append(allErrs, field.Required(fieldPathWithIndex.Child("valueFrom").Child("name"),
fmt.Sprintf("optionValue %s of type secret must reference a secret by name", val.Name)))
@@ -396,12 +402,11 @@ func validatePluginForCluster(ctx context.Context, c client.Client, plugin *gree
return nil
}
-// hasExactlyOneValueSource checks if exactly one of Value, ValueFrom, or Expression is set.
+// hasExactlyOneValueSource checks if exactly one of Value or ValueFrom is set.
func hasExactlyOneValueSource(val greenhousev1alpha1.PluginOptionValue) bool {
sources := []bool{
val.Value != nil,
val.ValueFrom != nil,
- val.Expression != nil,
}
count := 0
diff --git a/internal/webhook/v1alpha1/plugin_webhook_test.go b/internal/webhook/v1alpha1/plugin_webhook_test.go
index a86f28df7..a1ded8e8f 100644
--- a/internal/webhook/v1alpha1/plugin_webhook_test.go
+++ b/internal/webhook/v1alpha1/plugin_webhook_test.go
@@ -23,13 +23,12 @@ import (
)
var _ = Describe("Validate Plugin OptionValues", func() {
- DescribeTable("Validate PluginType contains either Value, ValueFrom, or Expression", func(value *apiextensionsv1.JSON, valueFrom *greenhousev1alpha1.PluginValueFromSource, expression *string, expErr bool) {
+ DescribeTable("Validate PluginType contains either Value or ValueFrom", func(value *apiextensionsv1.JSON, valueFrom *greenhousev1alpha1.PluginValueFromSource, expErr bool) {
optionValues := []greenhousev1alpha1.PluginOptionValue{
{
- Name: "test",
- Value: value,
- ValueFrom: valueFrom,
- Expression: expression,
+ Name: "test",
+ Value: value,
+ ValueFrom: valueFrom,
},
}
@@ -69,14 +68,10 @@ var _ = Describe("Validate Plugin OptionValues", func() {
Expect(errList).To(BeEmpty(), "expected no error, got %v", errList)
}
},
- Entry("Value and ValueFrom and Expression nil", nil, nil, nil, true),
- Entry("Value and ValueFrom not nil, Expression is nil", test.MustReturnJSONFor("test"), &greenhousev1alpha1.PluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret"}}, nil, true),
- Entry("Value not nil", test.MustReturnJSONFor("test"), nil, nil, false),
- Entry("ValueFrom not nil", nil, &greenhousev1alpha1.PluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret", Key: "secret-key"}}, nil, false),
- Entry("Expression not nil", nil, nil, new("${global.greenhouse.clusterName}"), false),
- Entry("Value and Expression not nil, ValueFrom nil", test.MustReturnJSONFor("test"), nil, new("${global.greenhouse.clusterName}"), true),
- Entry("ValueFrom and Expression not nil, Value nil", nil, &greenhousev1alpha1.PluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret", Key: "secret-key"}}, new("${global.greenhouse.clusterName}"), true),
- Entry("Value, ValueFrom, and Expression all not nil", test.MustReturnJSONFor("test"), &greenhousev1alpha1.PluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret", Key: "secret-key"}}, new("${global.greenhouse.clusterName}"), true),
+ Entry("Value and ValueFrom nil", nil, nil, 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, ValueFrom all not nil", test.MustReturnJSONFor("test"), &greenhousev1alpha1.PluginValueFromSource{Secret: &greenhousev1alpha1.SecretKeyReference{Name: "my-secret", Key: "secret-key"}}, true),
)
DescribeTable("Validate PluginOptionValue is consistent with PluginOption Type", func(defaultValue any, defaultType greenhousev1alpha1.PluginOptionType, actValue any, expErr bool) {
@@ -207,12 +202,6 @@ var _ = Describe("Validate Plugin OptionValues", func() {
},
},
}, false),
- Entry("required option provided with Expression", []greenhousev1alpha1.PluginOptionValue{
- {
- Name: "test",
- Expression: new("${global.greenhouse.clusterName}"),
- },
- }, false),
)
})
@@ -330,30 +319,6 @@ var _ = Describe("Validate plugin spec fields", Ordered, func() {
expectClusterNotFoundError(test.K8sClient.Create(test.Ctx, testPlugin))
})
- It("should keep the expression field when merging option values", func() {
- By("creating the plugin")
- testPlugin = setup.CreatePlugin(test.Ctx, "test-plugin",
- test.WithPluginDefinition(testPluginDefinition.Name),
- test.WithCluster(testCluster.Name),
- test.WithReleaseNamespace("test-namespace"),
- test.WithReleaseName("test-release"),
- test.WithPluginLabel(greenhouseapis.LabelKeyOwnedBy, team.Name),
- test.WithPluginOptionValueExpression("expressionOption", new("${global.greenhouse.clusterName}")))
-
- By("checking that the label is kept after merging options and optionvalues")
- actPlugin := &greenhousev1alpha1.Plugin{}
- err := test.K8sClient.Get(test.Ctx, types.NamespacedName{Name: testPlugin.Name, Namespace: testPlugin.Namespace}, actPlugin)
- Expect(err).ToNot(HaveOccurred(), "there should be no error getting the plugin")
- Eventually(func() bool {
- for _, actOption := range actPlugin.Spec.OptionValues {
- if actOption.Name == "expressionOption" {
- return actOption.Expression != nil
- }
- }
- return false
- }).Should(BeTrue(), "the plugin should have the expression field set on the optionValue")
- })
-
It("should accept the plugin with reference to ClusterPluginDefinition", func() {
By("creating the plugin")
testPlugin = setup.CreatePlugin(test.Ctx, "test-plugin",
diff --git a/internal/webhook/v1alpha1/pluginpreset_webhook.go b/internal/webhook/v1alpha1/pluginpreset_webhook.go
index 3d4ef81e0..5601fb731 100644
--- a/internal/webhook/v1alpha1/pluginpreset_webhook.go
+++ b/internal/webhook/v1alpha1/pluginpreset_webhook.go
@@ -134,11 +134,10 @@ func convertPresetToPluginOptionValues(presetValues []greenhousev1alpha1.PluginP
result := make([]greenhousev1alpha1.PluginOptionValue, 0, len(presetValues))
for _, pv := range presetValues {
ov := greenhousev1alpha1.PluginOptionValue{
- Name: pv.Name,
- Value: pv.Value,
- Expression: pv.Expression,
+ Name: pv.Name,
+ Value: pv.Value,
}
- if pv.ValueFrom != nil {
+ if pv.ValueFrom != nil && pv.ValueFrom.Secret != nil {
ov.ValueFrom = &greenhousev1alpha1.PluginValueFromSource{
Secret: pv.ValueFrom.Secret,
}
diff --git a/internal/webhook/v1alpha1/pluginpreset_webhook_test.go b/internal/webhook/v1alpha1/pluginpreset_webhook_test.go
index 47ea6eaf4..2e4fadfc6 100644
--- a/internal/webhook/v1alpha1/pluginpreset_webhook_test.go
+++ b/internal/webhook/v1alpha1/pluginpreset_webhook_test.go
@@ -304,8 +304,8 @@ var _ = Describe("Validate Plugin OptionValues for PluginPreset", func() {
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 only (invalid)", nil, nil, utils.StringP(`"test-${global.greenhouse.clusterName}"`), true),
+ Entry("Expression and Value both set (valid)", test.MustReturnJSONFor("test"), nil, utils.StringP(`"test-expression"`), false),
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),
)
@@ -386,8 +386,10 @@ var _ = Describe("Validate Plugin OptionValues for PluginPreset", func() {
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),
+ // TODO: Restore once tests are adapted for expression validation moving from Plugin to PluginPreset.
+ //nolint:dupword
+ // 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),
)
diff --git a/types/typescript/schema.d.ts b/types/typescript/schema.d.ts
index 002658326..d88894236 100644
--- a/types/typescript/schema.d.ts
+++ b/types/typescript/schema.d.ts
@@ -1188,71 +1188,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.
- *
- * 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;
/** @description Value is the actual value in plain text. */
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.
- */
- ref?: {
- /** @description Expression is a CEL expression to extract the value from the referenced resource */
- expression: string;
- /**
- * @description Kind is the resource kind to target
- * if not set, defaults to the same kind as the referencing resource (Plugin or PluginPreset)
- * @enum {string}
- */
- kind?: "Plugin" | "PluginPreset";
- /**
- * @description Name is the name of the resource to target
- * this field is mutually exclusive with LabelSelector
- */
- name?: string;
- /**
- * @description Selector selects the resources to target based on labels
- * this field is mutually exclusive with Name
- */
- selector?: {
- /** @description matchExpressions is a list of label selector requirements. The requirements are ANDed. */
- matchExpressions?: {
- /** @description key is the label key that the selector applies to. */
- key: string;
- /**
- * @description operator represents a key's relationship to a set of values.
- * Valid operators are In, NotIn, Exists and DoesNotExist.
- */
- operator: string;
- /**
- * @description values is an array of string values. If the operator is In or NotIn,
- * the values array must be non-empty. If the operator is Exists or DoesNotExist,
- * the values array must be empty. This array is replaced during a strategic
- * merge patch.
- */
- values?: string[];
- }[];
- /**
- * @description matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
- * map is equivalent to an element of matchExpressions, whose key field is "key", the
- * operator is "In", and the values array contains only "value". The requirements are ANDed.
- */
- matchLabels?: {
- [key: string]: string;
- };
- };
- };
/** @description Secret references the v1.Secret containing the value that needs to be extracted */
secret?: {
/** @description Key in the secret to select the value from. */
@@ -1380,11 +1321,6 @@ export interface components {
type: string;
}[];
};
- /**
- * @description TrackedObjects contains a list of objects being tracked via the greenhouse.sap/tracking-id annotation.
- * Each entry is in the format "kind/name" (e.g., "Plugin/my-plugin").
- */
- trackedObjects?: string[];
/** @description UIApplication contains a reference to the frontend that is used for the deployed pluginDefinition version. */
uiApplication?: {
/** @description Name of the UI application. */
|