From f8d0a5adc55dd1bd212334d66fea3fb8cbb1bb59 Mon Sep 17 00:00:00 2001 From: Anik Bhattacharjee Date: Thu, 29 Jan 2026 16:37:55 -0500 Subject: [PATCH] feat: Add DeploymentConfig support to registry+v1 bundle renderer This PR implements **Phase 2** of the [Deployment Configuration RFC](https://docs.google.com/document/d/18O4qBvu5I4WIJgo5KU1opyUKcrfgk64xsI3tyXxmVEU/edit?tab=t.0): extending the OLMv1 bundle renderer to apply `DeploymentConfig` customizations to operator deployments. Building on the foundation from #2454, this PR enables the renderer to accept and apply deployment configuration when rendering registry+v1 bundles. The implementation follows OLMv0's behavior patterns to ensure compatibility and correctness. The next PR will wire up the config in the `ClusterExtension` controller by parsing `spec.install.config` to convert to `DeploymentConfig` and thread `DeploymentConfig` through the controller's render call chain --- internal/operator-controller/config/config.go | 6 + .../generators/deployment_config_test.go | 1006 +++++++++++++++++ .../registryv1/generators/generators.go | 252 +++++ .../registryv1/generators/generators_test.go | 585 ++++++++++ .../rukpak/render/render.go | 12 + .../rukpak/render/render_test.go | 77 ++ 6 files changed, 1938 insertions(+) create mode 100644 internal/operator-controller/rukpak/render/registryv1/generators/deployment_config_test.go diff --git a/internal/operator-controller/config/config.go b/internal/operator-controller/config/config.go index 43f755762c..7b8af0b846 100644 --- a/internal/operator-controller/config/config.go +++ b/internal/operator-controller/config/config.go @@ -30,6 +30,8 @@ import ( "fmt" "strings" + "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/santhosh-tekuri/jsonschema/v6" "sigs.k8s.io/yaml" ) @@ -47,6 +49,10 @@ const ( FormatSingleNamespaceInstallMode = "singleNamespaceInstallMode" ) +// DeploymentConfig is a type alias for v1alpha1.SubscriptionConfig +// to maintain clear naming in the OLMv1 context while reusing the v0 type. +type DeploymentConfig = v1alpha1.SubscriptionConfig + // SchemaProvider lets each package format type describe what configuration it accepts. // // Different package format types provide schemas in different ways: diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/deployment_config_test.go b/internal/operator-controller/rukpak/render/registryv1/generators/deployment_config_test.go new file mode 100644 index 0000000000..6551650a6e --- /dev/null +++ b/internal/operator-controller/rukpak/render/registryv1/generators/deployment_config_test.go @@ -0,0 +1,1006 @@ +package generators + +import ( + "testing" + + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + "github.com/operator-framework/operator-controller/internal/operator-controller/config" +) + +func Test_applyCustomConfigToDeployment(t *testing.T) { + t.Run("nil config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test"}, + }, + }, + }, + }, + } + original := dep.DeepCopy() + applyCustomConfigToDeployment(dep, nil) + require.Equal(t, original, dep) + }) + + t.Run("empty config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test"}, + }, + }, + }, + }, + } + original := dep.DeepCopy() + applyCustomConfigToDeployment(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("applies all config fields", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test", + Env: []corev1.EnvVar{ + {Name: "EXISTING", Value: "value"}, + }, + }, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Env: []corev1.EnvVar{ + {Name: "NEW_ENV", Value: "new_value"}, + }, + Tolerations: []corev1.Toleration{ + {Key: "key1", Operator: corev1.TolerationOpEqual, Value: "value1"}, + }, + Resources: &corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + NodeSelector: map[string]string{ + "disk": "ssd", + }, + } + + applyCustomConfigToDeployment(dep, config) + + // Verify env was applied + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 2) + require.Contains(t, dep.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{Name: "NEW_ENV", Value: "new_value"}) + + // Verify tolerations were applied + require.Len(t, dep.Spec.Template.Spec.Tolerations, 1) + + // Verify resources were applied + require.NotNil(t, dep.Spec.Template.Spec.Containers[0].Resources.Requests) + + // Verify node selector was applied + require.Equal(t, map[string]string{"disk": "ssd"}, dep.Spec.Template.Spec.NodeSelector) + }) +} + +func Test_applyEnvironmentConfig(t *testing.T) { + t.Run("empty env config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test", Env: []corev1.EnvVar{{Name: "EXISTING", Value: "value"}}}, + }, + }, + }, + }, + } + original := dep.DeepCopy() + applyEnvironmentConfig(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("appends new env vars", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test", Env: []corev1.EnvVar{{Name: "EXISTING", Value: "value"}}}, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Env: []corev1.EnvVar{ + {Name: "NEW_VAR", Value: "new_value"}, + }, + } + + applyEnvironmentConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 2) + require.Contains(t, dep.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{Name: "EXISTING", Value: "value"}) + require.Contains(t, dep.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{Name: "NEW_VAR", Value: "new_value"}) + }) + + t.Run("overrides existing env vars with same name", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test", + Env: []corev1.EnvVar{ + {Name: "VAR1", Value: "old_value"}, + {Name: "VAR2", Value: "keep_value"}, + }, + }, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Env: []corev1.EnvVar{ + {Name: "VAR1", Value: "new_value"}, + }, + } + + applyEnvironmentConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 2) + require.Equal(t, "new_value", dep.Spec.Template.Spec.Containers[0].Env[0].Value) + require.Equal(t, "keep_value", dep.Spec.Template.Spec.Containers[0].Env[1].Value) + }) + + t.Run("applies to all containers", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "container1"}, + {Name: "container2"}, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Env: []corev1.EnvVar{ + {Name: "SHARED_VAR", Value: "value"}, + }, + } + + applyEnvironmentConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) + require.Len(t, dep.Spec.Template.Spec.Containers[1].Env, 1) + require.Equal(t, "SHARED_VAR", dep.Spec.Template.Spec.Containers[0].Env[0].Name) + require.Equal(t, "SHARED_VAR", dep.Spec.Template.Spec.Containers[1].Env[0].Name) + }) +} + +func Test_applyEnvironmentFromConfig(t *testing.T) { + t.Run("empty envFrom config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test"}, + }, + }, + }, + }, + } + original := dep.DeepCopy() + applyEnvironmentFromConfig(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("appends new envFrom sources", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test"}, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + }, + }, + } + + applyEnvironmentFromConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Containers[0].EnvFrom, 1) + require.Equal(t, "config1", dep.Spec.Template.Spec.Containers[0].EnvFrom[0].ConfigMapRef.Name) + }) + + t.Run("does not add duplicate envFrom sources", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + }, + }, + } + + applyEnvironmentFromConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Containers[0].EnvFrom, 1) + }) + + t.Run("appends different envFrom sources", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test", + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}, + }, + }, + }, + } + + applyEnvironmentFromConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Containers[0].EnvFrom, 2) + }) +} + +func Test_envFromEquals(t *testing.T) { + t.Run("equal ConfigMapRef sources", func(t *testing.T) { + a := corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + } + b := corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + } + require.True(t, envFromEquals(a, b)) + }) + + t.Run("different ConfigMapRef names", func(t *testing.T) { + a := corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + } + b := corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config2"}, + }, + } + require.False(t, envFromEquals(a, b)) + }) + + t.Run("different source types", func(t *testing.T) { + a := corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + } + b := corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}, + }, + } + require.False(t, envFromEquals(a, b)) + }) + + t.Run("equal SecretRef sources", func(t *testing.T) { + a := corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}, + }, + } + b := corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}, + }, + } + require.True(t, envFromEquals(a, b)) + }) + + t.Run("different prefixes", func(t *testing.T) { + a := corev1.EnvFromSource{ + Prefix: "prefix1", + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + } + b := corev1.EnvFromSource{ + Prefix: "prefix2", + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "config1"}, + }, + } + require.False(t, envFromEquals(a, b)) + }) +} + +func Test_applyVolumeConfig(t *testing.T) { + t.Run("empty volume config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{}, + }, + }, + } + original := dep.DeepCopy() + applyVolumeConfig(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("appends volumes", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + {Name: "existing-vol"}, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Volumes: []corev1.Volume{ + {Name: "new-vol"}, + }, + } + + applyVolumeConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Volumes, 2) + require.Equal(t, "existing-vol", dep.Spec.Template.Spec.Volumes[0].Name) + require.Equal(t, "new-vol", dep.Spec.Template.Spec.Volumes[1].Name) + }) +} + +func Test_applyVolumeMountConfig(t *testing.T) { + t.Run("empty volumeMount config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test"}, + }, + }, + }, + }, + } + original := dep.DeepCopy() + applyVolumeMountConfig(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("appends volume mounts to all containers", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container1", + VolumeMounts: []corev1.VolumeMount{ + {Name: "existing", MountPath: "/existing"}, + }, + }, + {Name: "container2"}, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + VolumeMounts: []corev1.VolumeMount{ + {Name: "new-mount", MountPath: "/new"}, + }, + } + + applyVolumeMountConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Containers[0].VolumeMounts, 2) + require.Len(t, dep.Spec.Template.Spec.Containers[1].VolumeMounts, 1) + require.Equal(t, "new-mount", dep.Spec.Template.Spec.Containers[0].VolumeMounts[1].Name) + require.Equal(t, "new-mount", dep.Spec.Template.Spec.Containers[1].VolumeMounts[0].Name) + }) +} + +func Test_applyTolerationsConfig(t *testing.T) { + t.Run("empty tolerations config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{}, + }, + }, + } + original := dep.DeepCopy() + applyTolerationsConfig(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("appends new tolerations", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{}, + }, + }, + } + + config := &config.DeploymentConfig{ + Tolerations: []corev1.Toleration{ + {Key: "key1", Operator: corev1.TolerationOpEqual, Value: "value1"}, + }, + } + + applyTolerationsConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Tolerations, 1) + require.Equal(t, "key1", dep.Spec.Template.Spec.Tolerations[0].Key) + }) + + t.Run("does not add duplicate tolerations", func(t *testing.T) { + toleration := corev1.Toleration{ + Key: "key1", + Operator: corev1.TolerationOpEqual, + Value: "value1", + Effect: corev1.TaintEffectNoSchedule, + } + + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Tolerations: []corev1.Toleration{toleration}, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Tolerations: []corev1.Toleration{toleration}, + } + + applyTolerationsConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Tolerations, 1) + }) + + t.Run("appends different tolerations", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Tolerations: []corev1.Toleration{ + {Key: "key1", Operator: corev1.TolerationOpEqual, Value: "value1"}, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Tolerations: []corev1.Toleration{ + {Key: "key2", Operator: corev1.TolerationOpEqual, Value: "value2"}, + }, + } + + applyTolerationsConfig(dep, config) + + require.Len(t, dep.Spec.Template.Spec.Tolerations, 2) + }) +} + +func Test_tolerationEquals(t *testing.T) { + t.Run("equal tolerations", func(t *testing.T) { + a := corev1.Toleration{ + Key: "key1", + Operator: corev1.TolerationOpEqual, + Value: "value1", + Effect: corev1.TaintEffectNoSchedule, + } + b := corev1.Toleration{ + Key: "key1", + Operator: corev1.TolerationOpEqual, + Value: "value1", + Effect: corev1.TaintEffectNoSchedule, + } + require.True(t, tolerationEquals(a, b)) + }) + + t.Run("different keys", func(t *testing.T) { + a := corev1.Toleration{Key: "key1"} + b := corev1.Toleration{Key: "key2"} + require.False(t, tolerationEquals(a, b)) + }) + + t.Run("different operators", func(t *testing.T) { + a := corev1.Toleration{Operator: corev1.TolerationOpEqual} + b := corev1.Toleration{Operator: corev1.TolerationOpExists} + require.False(t, tolerationEquals(a, b)) + }) + + t.Run("different values", func(t *testing.T) { + a := corev1.Toleration{Value: "value1"} + b := corev1.Toleration{Value: "value2"} + require.False(t, tolerationEquals(a, b)) + }) + + t.Run("different effects", func(t *testing.T) { + a := corev1.Toleration{Effect: corev1.TaintEffectNoSchedule} + b := corev1.Toleration{Effect: corev1.TaintEffectPreferNoSchedule} + require.False(t, tolerationEquals(a, b)) + }) + + t.Run("equal with TolerationSeconds", func(t *testing.T) { + a := corev1.Toleration{ + Key: "key1", + TolerationSeconds: ptr.To(int64(300)), + } + b := corev1.Toleration{ + Key: "key1", + TolerationSeconds: ptr.To(int64(300)), + } + require.True(t, tolerationEquals(a, b)) + }) + + t.Run("different TolerationSeconds", func(t *testing.T) { + a := corev1.Toleration{ + Key: "key1", + TolerationSeconds: ptr.To(int64(300)), + } + b := corev1.Toleration{ + Key: "key1", + TolerationSeconds: ptr.To(int64(600)), + } + require.False(t, tolerationEquals(a, b)) + }) + + t.Run("one nil TolerationSeconds", func(t *testing.T) { + a := corev1.Toleration{ + Key: "key1", + TolerationSeconds: ptr.To(int64(300)), + } + b := corev1.Toleration{ + Key: "key1", + } + require.False(t, tolerationEquals(a, b)) + }) +} + +func Test_applyResourcesConfig(t *testing.T) { + t.Run("nil resources config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test"}, + }, + }, + }, + }, + } + original := dep.DeepCopy() + applyResourcesConfig(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("replaces resources for all containers", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container1", + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + }, + { + Name: "container2", + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + }, + }, + }, + }, + }, + }, + }, + } + + newResources := &corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1000m"), + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + } + + config := &config.DeploymentConfig{ + Resources: newResources, + } + + applyResourcesConfig(dep, config) + + // Verify both containers have the new resources + require.Equal(t, *newResources, dep.Spec.Template.Spec.Containers[0].Resources) + require.Equal(t, *newResources, dep.Spec.Template.Spec.Containers[1].Resources) + }) +} + +func Test_applyNodeSelectorConfig(t *testing.T) { + t.Run("nil nodeSelector config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{}, + }, + }, + } + original := dep.DeepCopy() + applyNodeSelectorConfig(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("replaces nodeSelector", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + NodeSelector: map[string]string{ + "disk": "hdd", + "zone": "us-east-1a", + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + NodeSelector: map[string]string{ + "disk": "ssd", + }, + } + + applyNodeSelectorConfig(dep, config) + + require.Equal(t, map[string]string{"disk": "ssd"}, dep.Spec.Template.Spec.NodeSelector) + }) +} + +func Test_applyAffinityConfig(t *testing.T) { + t.Run("nil affinity config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{}, + }, + }, + } + original := dep.DeepCopy() + applyAffinityConfig(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("sets affinity when deployment has none", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{}, + }, + }, + } + + nodeAffinity := &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{ + { + MatchExpressions: []corev1.NodeSelectorRequirement{ + {Key: "key1", Operator: corev1.NodeSelectorOpIn, Values: []string{"value1"}}, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Affinity: &corev1.Affinity{ + NodeAffinity: nodeAffinity, + }, + } + + applyAffinityConfig(dep, config) + + require.NotNil(t, dep.Spec.Template.Spec.Affinity) + require.Equal(t, nodeAffinity, dep.Spec.Template.Spec.Affinity.NodeAffinity) + }) + + t.Run("selectively overrides affinity sub-attributes", func(t *testing.T) { + existingNodeAffinity := &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{ + { + MatchExpressions: []corev1.NodeSelectorRequirement{ + {Key: "existing", Operator: corev1.NodeSelectorOpIn, Values: []string{"value"}}, + }, + }, + }, + }, + } + + existingPodAffinity := &corev1.PodAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: []corev1.PodAffinityTerm{ + { + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "existing"}, + }, + }, + }, + } + + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Affinity: &corev1.Affinity{ + NodeAffinity: existingNodeAffinity, + PodAffinity: existingPodAffinity, + }, + }, + }, + }, + } + + newNodeAffinity := &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{ + { + MatchExpressions: []corev1.NodeSelectorRequirement{ + {Key: "new", Operator: corev1.NodeSelectorOpIn, Values: []string{"value"}}, + }, + }, + }, + }, + } + + newPodAntiAffinity := &corev1.PodAntiAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: []corev1.PodAffinityTerm{ + { + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "new"}, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Affinity: &corev1.Affinity{ + NodeAffinity: newNodeAffinity, + PodAntiAffinity: newPodAntiAffinity, + }, + } + + applyAffinityConfig(dep, config) + + // NodeAffinity should be replaced + require.Equal(t, newNodeAffinity, dep.Spec.Template.Spec.Affinity.NodeAffinity) + // PodAffinity should remain unchanged + require.Equal(t, existingPodAffinity, dep.Spec.Template.Spec.Affinity.PodAffinity) + // PodAntiAffinity should be set + require.Equal(t, newPodAntiAffinity, dep.Spec.Template.Spec.Affinity.PodAntiAffinity) + }) + + t.Run("does not override with nil sub-attributes", func(t *testing.T) { + existingNodeAffinity := &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{ + { + MatchExpressions: []corev1.NodeSelectorRequirement{ + {Key: "existing", Operator: corev1.NodeSelectorOpIn, Values: []string{"value"}}, + }, + }, + }, + }, + } + + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Affinity: &corev1.Affinity{ + NodeAffinity: existingNodeAffinity, + }, + }, + }, + }, + } + + config := &config.DeploymentConfig{ + Affinity: &corev1.Affinity{ + // NodeAffinity is nil, should not override + PodAffinity: &corev1.PodAffinity{}, // Non-nil, should be set + }, + } + + applyAffinityConfig(dep, config) + + // NodeAffinity should remain unchanged + require.Equal(t, existingNodeAffinity, dep.Spec.Template.Spec.Affinity.NodeAffinity) + // PodAffinity should be set + require.NotNil(t, dep.Spec.Template.Spec.Affinity.PodAffinity) + }) +} + +func Test_applyAnnotationsConfig(t *testing.T) { + t.Run("empty annotations config does nothing", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{}, + }, + }, + } + original := dep.DeepCopy() + applyAnnotationsConfig(dep, &config.DeploymentConfig{}) + require.Equal(t, original, dep) + }) + + t.Run("adds new annotations to deployment and pod", func(t *testing.T) { + dep := &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{}, + }, + }, + } + + config := &config.DeploymentConfig{ + Annotations: map[string]string{ + "new-annotation": "value", + }, + } + + applyAnnotationsConfig(dep, config) + + require.Equal(t, "value", dep.Annotations["new-annotation"]) + require.Equal(t, "value", dep.Spec.Template.Annotations["new-annotation"]) + }) + + t.Run("existing annotations take precedence", func(t *testing.T) { + dep := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "existing-key": "existing-value", + }, + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "pod-existing-key": "pod-existing-value", + }, + }, + Spec: corev1.PodSpec{}, + }, + }, + } + + config := &config.DeploymentConfig{ + Annotations: map[string]string{ + "existing-key": "config-value", + "pod-existing-key": "config-value", + "new-key": "new-value", + }, + } + + applyAnnotationsConfig(dep, config) + + // Existing deployment annotation should not be overridden + require.Equal(t, "existing-value", dep.Annotations["existing-key"]) + // New deployment annotation should be added + require.Equal(t, "new-value", dep.Annotations["new-key"]) + // Existing pod annotation should not be overridden + require.Equal(t, "pod-existing-value", dep.Spec.Template.Annotations["pod-existing-key"]) + // New pod annotation should be added + require.Equal(t, "new-value", dep.Spec.Template.Annotations["new-key"]) + }) +} diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go index 7d5d435ead..728a57250c 100644 --- a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go +++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go @@ -21,6 +21,7 @@ import ( "github.com/operator-framework/api/pkg/operators/v1alpha1" registrybundle "github.com/operator-framework/operator-registry/pkg/lib/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/config" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" @@ -98,6 +99,9 @@ func BundleCSVDeploymentGenerator(rv1 *bundle.RegistryV1, opts render.Options) ( ensureCorrectDeploymentCertVolumes(deploymentResource, *secretInfo) } + // Apply deployment configuration if provided + applyCustomConfigToDeployment(deploymentResource, opts.DeploymentConfig) + objs = append(objs, deploymentResource) } return objs, nil @@ -578,3 +582,251 @@ func getWebhookNamespaceSelector(targetNamespaces []string) *metav1.LabelSelecto } return nil } + +// applyCustomConfigToDeployment applies the deployment configuration to all containers in the deployment. +// It follows OLMv0 behavior for applying configuration to deployments. +// See https://github.com/operator-framework/operator-lifecycle-manager/blob/master/pkg/controller/operators/catalog/subscription/config_types.go +func applyCustomConfigToDeployment(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if config == nil { + return + } + + // Apply all configuration modifications following OLMv0 behavior + applyEnvironmentConfig(deployment, config) + applyEnvironmentFromConfig(deployment, config) + applyVolumeConfig(deployment, config) + applyVolumeMountConfig(deployment, config) + applyTolerationsConfig(deployment, config) + applyResourcesConfig(deployment, config) + applyNodeSelectorConfig(deployment, config) + applyAffinityConfig(deployment, config) + applyAnnotationsConfig(deployment, config) +} + +// applyEnvironmentConfig applies environment variables to all containers in the deployment. +// Environment variables from config override existing environment variables with the same name. +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/catalog/subscription/subscription_config.go#L73-L90 +func applyEnvironmentConfig(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if len(config.Env) == 0 { + return + } + + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + + // Create a map to track existing env var names for override behavior + existingEnvMap := make(map[string]int) + for idx, env := range container.Env { + existingEnvMap[env.Name] = idx + } + + // Apply config env vars, overriding existing ones with same name + for _, configEnv := range config.Env { + if existingIdx, exists := existingEnvMap[configEnv.Name]; exists { + // Override existing env var + container.Env[existingIdx] = configEnv + } else { + // Append new env var + container.Env = append(container.Env, configEnv) + } + } + } +} + +// applyEnvironmentFromConfig appends EnvFrom sources to all containers in the deployment. +// Duplicate EnvFrom sources are not added. +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/catalog/subscription/subscription_config.go#L92-L106 +func applyEnvironmentFromConfig(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if len(config.EnvFrom) == 0 { + return + } + + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + + // Check for duplicates before appending + for _, configEnvFrom := range config.EnvFrom { + isDuplicate := false + for _, existingEnvFrom := range container.EnvFrom { + if envFromEquals(existingEnvFrom, configEnvFrom) { + isDuplicate = true + break + } + } + if !isDuplicate { + container.EnvFrom = append(container.EnvFrom, configEnvFrom) + } + } + } +} + +// envFromEquals checks if two EnvFromSource objects are equal. +func envFromEquals(a, b corev1.EnvFromSource) bool { + if a.Prefix != b.Prefix { + return false + } + + if (a.ConfigMapRef == nil) != (b.ConfigMapRef == nil) { + return false + } + if a.ConfigMapRef != nil && b.ConfigMapRef != nil { + if a.ConfigMapRef.Name != b.ConfigMapRef.Name { + return false + } + } + + if (a.SecretRef == nil) != (b.SecretRef == nil) { + return false + } + if a.SecretRef != nil && b.SecretRef != nil { + if a.SecretRef.Name != b.SecretRef.Name { + return false + } + } + + return true +} + +// applyVolumeConfig appends volumes to the deployment's pod spec. +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/catalog/subscription/subscription_config.go#L108-L113 +func applyVolumeConfig(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if len(config.Volumes) == 0 { + return + } + + deployment.Spec.Template.Spec.Volumes = append(deployment.Spec.Template.Spec.Volumes, config.Volumes...) +} + +// applyVolumeMountConfig appends volume mounts to all containers in the deployment. +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/catalog/subscription/subscription_config.go#L115-L122 +func applyVolumeMountConfig(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if len(config.VolumeMounts) == 0 { + return + } + + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + container.VolumeMounts = append(container.VolumeMounts, config.VolumeMounts...) + } +} + +// applyTolerationsConfig appends tolerations to the deployment's pod spec. +// Duplicate tolerations are not added. +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/catalog/subscription/subscription_config.go#L124-L138 +func applyTolerationsConfig(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if len(config.Tolerations) == 0 { + return + } + + // Check for duplicates before appending + for _, configToleration := range config.Tolerations { + isDuplicate := false + for _, existingToleration := range deployment.Spec.Template.Spec.Tolerations { + if tolerationEquals(existingToleration, configToleration) { + isDuplicate = true + break + } + } + if !isDuplicate { + deployment.Spec.Template.Spec.Tolerations = append(deployment.Spec.Template.Spec.Tolerations, configToleration) + } + } +} + +// tolerationEquals checks if two Toleration objects are equal. +func tolerationEquals(a, b corev1.Toleration) bool { + return a.Key == b.Key && + a.Operator == b.Operator && + a.Value == b.Value && + a.Effect == b.Effect && + ((a.TolerationSeconds == nil && b.TolerationSeconds == nil) || + (a.TolerationSeconds != nil && b.TolerationSeconds != nil && *a.TolerationSeconds == *b.TolerationSeconds)) +} + +// applyResourcesConfig applies resource requirements to all containers in the deployment. +// This completely replaces existing resource requirements. +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/catalog/subscription/subscription_config.go#L140-L147 +func applyResourcesConfig(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if config.Resources == nil { + return + } + + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + container.Resources = *config.Resources + } +} + +// applyNodeSelectorConfig applies node selector to the deployment's pod spec. +// This completely replaces existing node selector. +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/catalog/subscription/subscription_config.go#L149-L154 +func applyNodeSelectorConfig(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if config.NodeSelector == nil { + return + } + + deployment.Spec.Template.Spec.NodeSelector = config.NodeSelector +} + +// applyAffinityConfig applies affinity configuration to the deployment's pod spec. +// This selectively overrides non-nil affinity sub-attributes. +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/catalog/subscription/subscription_config.go#L156-L179 +func applyAffinityConfig(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if config.Affinity == nil { + return + } + + if deployment.Spec.Template.Spec.Affinity == nil { + deployment.Spec.Template.Spec.Affinity = &corev1.Affinity{} + } + + if config.Affinity.NodeAffinity != nil { + deployment.Spec.Template.Spec.Affinity.NodeAffinity = config.Affinity.NodeAffinity + } + + if config.Affinity.PodAffinity != nil { + deployment.Spec.Template.Spec.Affinity.PodAffinity = config.Affinity.PodAffinity + } + + if config.Affinity.PodAntiAffinity != nil { + deployment.Spec.Template.Spec.Affinity.PodAntiAffinity = config.Affinity.PodAntiAffinity + } +} + +// applyAnnotationsConfig applies annotations to the deployment and its pod template. +// Existing deployment and pod annotations take precedence over config annotations (no override). +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/catalog/subscription/subscription_config.go#L181-L204 +func applyAnnotationsConfig(deployment *appsv1.Deployment, config *config.DeploymentConfig) { + if len(config.Annotations) == 0 { + return + } + + // Apply to deployment metadata + if deployment.Annotations == nil { + deployment.Annotations = make(map[string]string) + } + for key, value := range config.Annotations { + if _, exists := deployment.Annotations[key]; !exists { + deployment.Annotations[key] = value + } + } + + // Apply to pod template metadata + if deployment.Spec.Template.Annotations == nil { + deployment.Spec.Template.Annotations = make(map[string]string) + } + for key, value := range config.Annotations { + if _, exists := deployment.Spec.Template.Annotations[key]; !exists { + deployment.Spec.Template.Annotations[key] = value + } + } +} diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go index 59be3c6df1..22ce6d28be 100644 --- a/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go +++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go @@ -12,6 +12,7 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/intstr" @@ -20,6 +21,7 @@ import ( "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/operator-framework/operator-controller/internal/operator-controller/config" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/registryv1/generators" @@ -2508,3 +2510,586 @@ func Test_CertProviderResourceGenerator_Succeeds(t *testing.T) { }), }, objs) } + +func Test_BundleCSVDeploymentGenerator_WithDeploymentConfig(t *testing.T) { + for _, tc := range []struct { + name string + bundle *bundle.RegistryV1 + opts render.Options + verify func(*testing.T, []client.Object) + }{ + { + name: "applies env vars from deployment config", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "manager", + Env: []corev1.EnvVar{ + {Name: "EXISTING_VAR", Value: "existing_value"}, + }, + }, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + Env: []corev1.EnvVar{ + {Name: "NEW_VAR", Value: "new_value"}, + {Name: "EXISTING_VAR", Value: "overridden_value"}, + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + require.Len(t, dep.Spec.Template.Spec.Containers, 1) + envVars := dep.Spec.Template.Spec.Containers[0].Env + + // Should have both vars + require.Len(t, envVars, 2) + + // Existing var should be overridden + var existingVar *corev1.EnvVar + for i := range envVars { + if envVars[i].Name == "EXISTING_VAR" { + existingVar = &envVars[i] + break + } + } + require.NotNil(t, existingVar) + require.Equal(t, "overridden_value", existingVar.Value) + + // New var should be added + var newVar *corev1.EnvVar + for i := range envVars { + if envVars[i].Name == "NEW_VAR" { + newVar = &envVars[i] + break + } + } + require.NotNil(t, newVar) + require.Equal(t, "new_value", newVar.Value) + }, + }, + { + name: "applies resources from deployment config", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "manager"}, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + Resources: &corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("200m"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + resources := dep.Spec.Template.Spec.Containers[0].Resources + + require.Equal(t, resource.MustParse("100m"), *resources.Requests.Cpu()) + require.Equal(t, resource.MustParse("128Mi"), *resources.Requests.Memory()) + require.Equal(t, resource.MustParse("200m"), *resources.Limits.Cpu()) + require.Equal(t, resource.MustParse("256Mi"), *resources.Limits.Memory()) + }, + }, + { + name: "applies tolerations from deployment config", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "manager"}, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + Tolerations: []corev1.Toleration{ + { + Key: "node.kubernetes.io/disk-type", + Operator: corev1.TolerationOpEqual, + Value: "ssd", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + tolerations := dep.Spec.Template.Spec.Tolerations + + require.Len(t, tolerations, 1) + require.Equal(t, "node.kubernetes.io/disk-type", tolerations[0].Key) + require.Equal(t, corev1.TolerationOpEqual, tolerations[0].Operator) + require.Equal(t, "ssd", tolerations[0].Value) + require.Equal(t, corev1.TaintEffectNoSchedule, tolerations[0].Effect) + }, + }, + { + name: "applies node selector from deployment config", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "manager"}, + }, + NodeSelector: map[string]string{ + "existing-key": "existing-value", + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + NodeSelector: map[string]string{ + "disk-type": "ssd", + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + + // Node selector should be replaced, not merged + require.Equal(t, map[string]string{"disk-type": "ssd"}, dep.Spec.Template.Spec.NodeSelector) + }, + }, + { + name: "applies affinity from deployment config", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "manager"}, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + Affinity: &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{ + { + MatchExpressions: []corev1.NodeSelectorRequirement{ + { + Key: "kubernetes.io/arch", + Operator: corev1.NodeSelectorOpIn, + Values: []string{"amd64", "arm64"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + + require.NotNil(t, dep.Spec.Template.Spec.Affinity) + require.NotNil(t, dep.Spec.Template.Spec.Affinity.NodeAffinity) + require.NotNil(t, dep.Spec.Template.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution) + require.Len(t, dep.Spec.Template.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms, 1) + }, + }, + { + name: "applies annotations from deployment config", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithAnnotations(map[string]string{ + "csv-annotation": "csv-value", + }). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "existing-pod-annotation": "existing-pod-value", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "manager"}, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + Annotations: map[string]string{ + "config-annotation": "config-value", + "existing-pod-annotation": "should-not-override", + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + + // Deployment annotations should include config annotations + // (CSV annotations are only merged into pod template by the generator) + require.Contains(t, dep.Annotations, "config-annotation") + require.Equal(t, "config-value", dep.Annotations["config-annotation"]) + + // Pod template annotations should include CSV annotations (merged by generator) + // and existing pod annotations should take precedence over config + require.Contains(t, dep.Spec.Template.Annotations, "csv-annotation") + require.Equal(t, "csv-value", dep.Spec.Template.Annotations["csv-annotation"]) + require.Contains(t, dep.Spec.Template.Annotations, "existing-pod-annotation") + require.Equal(t, "existing-pod-value", dep.Spec.Template.Annotations["existing-pod-annotation"]) + require.Contains(t, dep.Spec.Template.Annotations, "config-annotation") + require.Equal(t, "config-value", dep.Spec.Template.Annotations["config-annotation"]) + }, + }, + { + name: "applies volumes and volume mounts from deployment config", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "manager"}, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + Volumes: []corev1.Volume{ + { + Name: "config-volume", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-config"}, + }, + }, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "config-volume", + MountPath: "/etc/config", + }, + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + + // Check volume was added + require.Len(t, dep.Spec.Template.Spec.Volumes, 1) + require.Equal(t, "config-volume", dep.Spec.Template.Spec.Volumes[0].Name) + + // Check volume mount was added to container + require.Len(t, dep.Spec.Template.Spec.Containers[0].VolumeMounts, 1) + require.Equal(t, "config-volume", dep.Spec.Template.Spec.Containers[0].VolumeMounts[0].Name) + require.Equal(t, "/etc/config", dep.Spec.Template.Spec.Containers[0].VolumeMounts[0].MountPath) + }, + }, + { + name: "applies envFrom from deployment config", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "manager"}, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "env-config"}, + }, + }, + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "env-secret"}, + }, + }, + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + + envFrom := dep.Spec.Template.Spec.Containers[0].EnvFrom + require.Len(t, envFrom, 2) + + // Check ConfigMap ref + require.NotNil(t, envFrom[0].ConfigMapRef) + require.Equal(t, "env-config", envFrom[0].ConfigMapRef.Name) + + // Check Secret ref + require.NotNil(t, envFrom[1].SecretRef) + require.Equal(t, "env-secret", envFrom[1].SecretRef.Name) + }, + }, + { + name: "applies all config fields together", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "manager"}, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + Env: []corev1.EnvVar{ + {Name: "ENV_VAR", Value: "value"}, + }, + Resources: &corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + Tolerations: []corev1.Toleration{ + {Key: "key1", Operator: corev1.TolerationOpEqual, Value: "value1"}, + }, + NodeSelector: map[string]string{ + "disk": "ssd", + }, + Annotations: map[string]string{ + "annotation-key": "annotation-value", + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + + // Verify env was applied + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) + require.Equal(t, "ENV_VAR", dep.Spec.Template.Spec.Containers[0].Env[0].Name) + + // Verify resources were applied + require.NotNil(t, dep.Spec.Template.Spec.Containers[0].Resources.Requests) + + // Verify tolerations were applied + require.Len(t, dep.Spec.Template.Spec.Tolerations, 1) + + // Verify node selector was applied + require.Equal(t, map[string]string{"disk": "ssd"}, dep.Spec.Template.Spec.NodeSelector) + + // Verify annotations were applied + require.Contains(t, dep.Annotations, "annotation-key") + require.Contains(t, dep.Spec.Template.Annotations, "annotation-key") + }, + }, + { + name: "applies config to multiple containers", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "container1"}, + {Name: "container2"}, + {Name: "container3"}, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: &config.DeploymentConfig{ + Env: []corev1.EnvVar{ + {Name: "SHARED_VAR", Value: "shared_value"}, + }, + Resources: &corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + }, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + + // All containers should have the env var + for i := range dep.Spec.Template.Spec.Containers { + container := dep.Spec.Template.Spec.Containers[i] + require.Len(t, container.Env, 1) + require.Equal(t, "SHARED_VAR", container.Env[0].Name) + require.Equal(t, "shared_value", container.Env[0].Value) + + // All containers should have the resources + require.NotNil(t, container.Resources.Requests) + require.Equal(t, resource.MustParse("100m"), *container.Resources.Requests.Cpu()) + } + }, + }, + { + name: "nil deployment config does nothing", + bundle: &bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "test-deployment", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "manager", + Env: []corev1.EnvVar{ + {Name: "EXISTING_VAR", Value: "existing_value"}, + }, + }, + }, + }, + }, + }, + }, + ).Build(), + }, + opts: render.Options{ + InstallNamespace: "test-ns", + TargetNamespaces: []string{"test-ns"}, + DeploymentConfig: nil, + }, + verify: func(t *testing.T, objs []client.Object) { + require.Len(t, objs, 1) + dep := objs[0].(*appsv1.Deployment) + + // Should only have the existing env var + require.Len(t, dep.Spec.Template.Spec.Containers[0].Env, 1) + require.Equal(t, "EXISTING_VAR", dep.Spec.Template.Spec.Containers[0].Env[0].Name) + require.Equal(t, "existing_value", dep.Spec.Template.Spec.Containers[0].Env[0].Value) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + objs, err := generators.BundleCSVDeploymentGenerator(tc.bundle, tc.opts) + require.NoError(t, err) + tc.verify(t, objs) + }) + } +} diff --git a/internal/operator-controller/rukpak/render/render.go b/internal/operator-controller/rukpak/render/render.go index f7e419c783..147218789c 100644 --- a/internal/operator-controller/rukpak/render/render.go +++ b/internal/operator-controller/rukpak/render/render.go @@ -10,6 +10,7 @@ import ( "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/operator-framework/operator-controller/internal/operator-controller/config" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" hashutil "github.com/operator-framework/operator-controller/internal/shared/util/hash" @@ -62,6 +63,9 @@ type Options struct { TargetNamespaces []string UniqueNameGenerator UniqueNameGenerator CertificateProvider CertificateProvider + // DeploymentConfig contains optional customizations to apply to CSV deployments. + // If nil, no customizations are applied. + DeploymentConfig *config.DeploymentConfig } func (o *Options) apply(opts ...Option) *Options { @@ -109,6 +113,14 @@ func WithCertificateProvider(provider CertificateProvider) Option { } } +// WithDeploymentConfig sets the deployment configuration to apply to CSV deployments. +// If config is nil, no customizations are applied. +func WithDeploymentConfig(config *config.DeploymentConfig) Option { + return func(o *Options) { + o.DeploymentConfig = config + } +} + type BundleRenderer struct { BundleValidator BundleValidator ResourceGenerators []ResourceGenerator diff --git a/internal/operator-controller/rukpak/render/render_test.go b/internal/operator-controller/rukpak/render/render_test.go index ca14598896..452f9f3fdb 100644 --- a/internal/operator-controller/rukpak/render/render_test.go +++ b/internal/operator-controller/rukpak/render/render_test.go @@ -13,6 +13,7 @@ import ( "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/operator-framework/operator-controller/internal/operator-controller/config" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" @@ -382,3 +383,79 @@ func Test_BundleValidatorCallsAllValidationFnsInOrder(t *testing.T) { require.NoError(t, val.Validate(nil)) require.Equal(t, "hi", actual) } + +func Test_WithDeploymentConfig(t *testing.T) { + t.Run("sets deployment config when provided", func(t *testing.T) { + expectedConfig := &config.DeploymentConfig{ + Env: []corev1.EnvVar{ + {Name: "TEST_ENV", Value: "test-value"}, + }, + } + + var receivedConfig *config.DeploymentConfig + renderer := render.BundleRenderer{ + ResourceGenerators: []render.ResourceGenerator{ + func(rv1 *bundle.RegistryV1, opts render.Options) ([]client.Object, error) { + receivedConfig = opts.DeploymentConfig + return nil, nil + }, + }, + } + + _, err := renderer.Render( + bundle.RegistryV1{ + CSV: clusterserviceversion.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(), + }, + "test-namespace", + render.WithDeploymentConfig(expectedConfig), + ) + + require.NoError(t, err) + require.Equal(t, expectedConfig, receivedConfig) + }) + + t.Run("deployment config is nil when not provided", func(t *testing.T) { + var receivedConfig *config.DeploymentConfig + renderer := render.BundleRenderer{ + ResourceGenerators: []render.ResourceGenerator{ + func(rv1 *bundle.RegistryV1, opts render.Options) ([]client.Object, error) { + receivedConfig = opts.DeploymentConfig + return nil, nil + }, + }, + } + + _, err := renderer.Render( + bundle.RegistryV1{ + CSV: clusterserviceversion.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(), + }, + "test-namespace", + ) + + require.NoError(t, err) + require.Nil(t, receivedConfig) + }) + + t.Run("deployment config is nil when explicitly set to nil", func(t *testing.T) { + var receivedConfig *config.DeploymentConfig + renderer := render.BundleRenderer{ + ResourceGenerators: []render.ResourceGenerator{ + func(rv1 *bundle.RegistryV1, opts render.Options) ([]client.Object, error) { + receivedConfig = opts.DeploymentConfig + return nil, nil + }, + }, + } + + _, err := renderer.Render( + bundle.RegistryV1{ + CSV: clusterserviceversion.Builder().WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces).Build(), + }, + "test-namespace", + render.WithDeploymentConfig(nil), + ) + + require.NoError(t, err) + require.Nil(t, receivedConfig) + }) +}