diff --git a/internal/operator-controller/config/config.go b/internal/operator-controller/config/config.go index 43f755762c..11d2fa58cd 100644 --- a/internal/operator-controller/config/config.go +++ b/internal/operator-controller/config/config.go @@ -32,6 +32,8 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" "sigs.k8s.io/yaml" + + "github.com/operator-framework/api/pkg/operators/v1alpha1" ) const ( @@ -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/generators.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go index 7d5d435ead..8f45bb7620 100644 --- a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go +++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go @@ -3,6 +3,7 @@ package generators import ( "cmp" "fmt" + "reflect" "slices" "strconv" "strings" @@ -21,6 +22,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 +100,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 +583,214 @@ 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/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.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/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.go#L11-L27 +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/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.go#L65-L81 +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 reflect.DeepEqual(existingEnvFrom, configEnvFrom) { + isDuplicate = true + break + } + } + if !isDuplicate { + container.EnvFrom = append(container.EnvFrom, configEnvFrom) + } + } + } +} + +// applyVolumeConfig appends volumes to the deployment's pod spec. +// This follows OLMv0 behavior: +// https://github.com/operator-framework/operator-lifecycle-manager/blob/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.go#L104-L117 +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/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.go#L149-L165 +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/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.go#L197-L209 +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 reflect.DeepEqual(existingToleration, configToleration) { + isDuplicate = true + break + } + } + if !isDuplicate { + deployment.Spec.Template.Spec.Tolerations = append(deployment.Spec.Template.Spec.Tolerations, configToleration) + } + } +} + +// 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/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.go#L236-L255 +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/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.go#L257-L271 +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/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.go#L273-L341 +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/v0.39.0/pkg/controller/operators/olm/overrides/inject/inject.go#L343-L378 +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..2de43f0f71 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 deploymentConfig is nil, no customizations are applied. +func WithDeploymentConfig(deploymentConfig *config.DeploymentConfig) Option { + return func(o *Options) { + o.DeploymentConfig = deploymentConfig + } +} + 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) + }) +}