diff --git a/api/bases/operator.openstack.org_openstacks.yaml b/api/bases/operator.openstack.org_openstacks.yaml index 3b72a29ac5..819572862f 100644 --- a/api/bases/operator.openstack.org_openstacks.yaml +++ b/api/bases/operator.openstack.org_openstacks.yaml @@ -57,6 +57,126 @@ spec: description: ControllerManager - tunings for the controller manager container properties: + env: + description: Env - Environment variables for the container + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array resources: description: |- Resources - Compute Resources for the service operator controller manager diff --git a/api/operator/v1beta1/openstack_types.go b/api/operator/v1beta1/openstack_types.go index 96b42bb517..4c696fdf14 100644 --- a/api/operator/v1beta1/openstack_types.go +++ b/api/operator/v1beta1/openstack_types.go @@ -214,6 +214,10 @@ type ContainerSpec struct { // Resources - Compute Resources for the service operator controller manager // https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ Resources corev1.ResourceRequirements `json:"resources,omitempty"` + + // +kubebuilder:validation:Optional + // Env - Environment variables for the container + Env []corev1.EnvVar `json:"env,omitempty"` } // OpenStackStatus defines the observed state of OpenStack diff --git a/api/operator/v1beta1/zz_generated.deepcopy.go b/api/operator/v1beta1/zz_generated.deepcopy.go index 601347ecf2..eee8bce9a5 100644 --- a/api/operator/v1beta1/zz_generated.deepcopy.go +++ b/api/operator/v1beta1/zz_generated.deepcopy.go @@ -30,6 +30,13 @@ import ( func (in *ContainerSpec) DeepCopyInto(out *ContainerSpec) { *out = *in in.Resources.DeepCopyInto(&out.Resources) + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]v1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerSpec. diff --git a/bindata/operator/managers.yaml b/bindata/operator/managers.yaml index e892da757c..ce723e8a67 100644 --- a/bindata/operator/managers.yaml +++ b/bindata/operator/managers.yaml @@ -41,8 +41,27 @@ spec: - /manager env: {{- range .Deployment.Manager.Env }} - - name: {{ .Name }} + - name: '{{ .Name }}' +{{- if .Value }} value: '{{ .Value }}' +{{- end }} +{{- if .ValueFrom }} + valueFrom: +{{- if .ValueFrom.FieldRef }} + fieldRef: + fieldPath: '{{ .ValueFrom.FieldRef.FieldPath }}' +{{- end }} +{{- if .ValueFrom.ConfigMapKeyRef }} + configMapKeyRef: + name: '{{ .ValueFrom.ConfigMapKeyRef.Name }}' + key: '{{ .ValueFrom.ConfigMapKeyRef.Key }}' +{{- end }} +{{- if .ValueFrom.SecretKeyRef }} + secretKeyRef: + name: '{{ .ValueFrom.SecretKeyRef.Name }}' + key: '{{ .ValueFrom.SecretKeyRef.Key }}' +{{- end }} +{{- end }} {{- end }} image: {{ .Deployment.Manager.Image }} livenessProbe: diff --git a/config/crd/bases/operator.openstack.org_openstacks.yaml b/config/crd/bases/operator.openstack.org_openstacks.yaml index 3b72a29ac5..819572862f 100644 --- a/config/crd/bases/operator.openstack.org_openstacks.yaml +++ b/config/crd/bases/operator.openstack.org_openstacks.yaml @@ -57,6 +57,126 @@ spec: description: ControllerManager - tunings for the controller manager container properties: + env: + description: Env - Environment variables for the container + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array resources: description: |- Resources - Compute Resources for the service operator controller manager diff --git a/config/operator/managers.yaml b/config/operator/managers.yaml index e892da757c..ce723e8a67 100644 --- a/config/operator/managers.yaml +++ b/config/operator/managers.yaml @@ -41,8 +41,27 @@ spec: - /manager env: {{- range .Deployment.Manager.Env }} - - name: {{ .Name }} + - name: '{{ .Name }}' +{{- if .Value }} value: '{{ .Value }}' +{{- end }} +{{- if .ValueFrom }} + valueFrom: +{{- if .ValueFrom.FieldRef }} + fieldRef: + fieldPath: '{{ .ValueFrom.FieldRef.FieldPath }}' +{{- end }} +{{- if .ValueFrom.ConfigMapKeyRef }} + configMapKeyRef: + name: '{{ .ValueFrom.ConfigMapKeyRef.Name }}' + key: '{{ .ValueFrom.ConfigMapKeyRef.Key }}' +{{- end }} +{{- if .ValueFrom.SecretKeyRef }} + secretKeyRef: + name: '{{ .ValueFrom.SecretKeyRef.Name }}' + key: '{{ .ValueFrom.SecretKeyRef.Key }}' +{{- end }} +{{- end }} {{- end }} image: {{ .Deployment.Manager.Image }} livenessProbe: diff --git a/internal/operator/override.go b/internal/operator/override.go index 8f68e2aa8e..2a11627d6a 100644 --- a/internal/operator/override.go +++ b/internal/operator/override.go @@ -111,11 +111,42 @@ func SetOverrides(opOvr operatorv1beta1.OperatorSpec, op *Operator) { op.Deployment.Manager.Resources.Requests.Memory = opOvr.ControllerManager.Resources.Requests.Memory().String() } } + if len(opOvr.ControllerManager.Env) > 0 { + op.Deployment.Manager.Env = mergeEnvVars(op.Deployment.Manager.Env, opOvr.ControllerManager.Env) + } if len(opOvr.Tolerations) > 0 { op.Deployment.Tolerations = mergeTolerations(op.Deployment.Tolerations, opOvr.Tolerations) } } +// mergeEnvVars merges custom environment variables with default environment variables. +// If a custom env var has the same name as a default one, it overrides the default. +// Otherwise, the custom env var is added to the list. +func mergeEnvVars(defaults, custom []corev1.EnvVar) []corev1.EnvVar { + if len(custom) == 0 { + return defaults + } + + // Start with a copy of defaults + merged := make([]corev1.EnvVar, len(defaults)) + copy(merged, defaults) + + // For each custom env var, check if it should override a default one + for _, customEnv := range custom { + f := func(c corev1.EnvVar) bool { + return c.Name == customEnv.Name + } + idx := slices.IndexFunc(merged, f) + if idx >= 0 { + merged[idx] = customEnv + } else { + merged = append(merged, customEnv) + } + } + + return merged +} + // mergeTolerations merges custom tolerations with default tolerations. // If a custom toleration has the same key as a default one, it overrides the default. // Otherwise, the custom toleration is added to the list. diff --git a/internal/operator/override_test.go b/internal/operator/override_test.go index 7f118114d3..ad625af829 100644 --- a/internal/operator/override_test.go +++ b/internal/operator/override_test.go @@ -619,6 +619,286 @@ func TestMergeTolerations(t *testing.T) { } } +// --- Test for mergeEnvVars function --- + +func TestMergeEnvVars(t *testing.T) { + defaultEnvVars := []corev1.EnvVar{ + { + Name: "OPERATOR_NAMESPACE", + Value: "default-namespace", + }, + { + Name: "LOG_LEVEL", + Value: "info", + }, + } + + testCases := []struct { + name string + defaults []corev1.EnvVar + custom []corev1.EnvVar + expected []corev1.EnvVar + }{ + { + name: "Empty custom env vars should return defaults", + defaults: defaultEnvVars, + custom: []corev1.EnvVar{}, + expected: defaultEnvVars, + }, + { + name: "Nil custom env vars should return defaults", + defaults: defaultEnvVars, + custom: nil, + expected: defaultEnvVars, + }, + { + name: "Add new env var to defaults", + defaults: defaultEnvVars, + custom: []corev1.EnvVar{ + { + Name: "OPERATOR_SCOPE_NAMESPACE", + Value: "namespace1,namespace2", + }, + }, + expected: []corev1.EnvVar{ + { + Name: "OPERATOR_NAMESPACE", + Value: "default-namespace", + }, + { + Name: "LOG_LEVEL", + Value: "info", + }, + { + Name: "OPERATOR_SCOPE_NAMESPACE", + Value: "namespace1,namespace2", + }, + }, + }, + { + name: "Override existing env var", + defaults: defaultEnvVars, + custom: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "debug", + }, + }, + expected: []corev1.EnvVar{ + { + Name: "OPERATOR_NAMESPACE", + Value: "default-namespace", + }, + { + Name: "LOG_LEVEL", + Value: "debug", // Overridden + }, + }, + }, + { + name: "Mixed: override one, add one", + defaults: defaultEnvVars, + custom: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "debug", + }, + { + Name: "OPERATOR_SCOPE_NAMESPACE", + Value: "namespace1,namespace2,namespace3", + }, + }, + expected: []corev1.EnvVar{ + { + Name: "OPERATOR_NAMESPACE", + Value: "default-namespace", + }, + { + Name: "LOG_LEVEL", + Value: "debug", // Overridden + }, + { + Name: "OPERATOR_SCOPE_NAMESPACE", + Value: "namespace1,namespace2,namespace3", // Added + }, + }, + }, + { + name: "Custom env var with valueFrom", + defaults: defaultEnvVars, + custom: []corev1.EnvVar{ + { + Name: "SECRET_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "my-secret", + }, + Key: "key", + }, + }, + }, + }, + expected: []corev1.EnvVar{ + { + Name: "OPERATOR_NAMESPACE", + Value: "default-namespace", + }, + { + Name: "LOG_LEVEL", + Value: "info", + }, + { + Name: "SECRET_KEY", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "my-secret", + }, + Key: "key", + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := mergeEnvVars(tc.defaults, tc.custom) + if !reflect.DeepEqual(result, tc.expected) { + t.Errorf("mergeEnvVars() failed:\n got: %+v\nwant: %+v", result, tc.expected) + } + }) + } +} + +// --- Test for environment variables in SetOverrides --- + +func TestEnvVarsOverride(t *testing.T) { + testCases := []struct { + name string + operatorSpec operatorv1beta1.OperatorSpec + initialEnvVars []corev1.EnvVar + expectedEnvVars []corev1.EnvVar + }{ + { + name: "Add env vars to empty list", + operatorSpec: operatorv1beta1.OperatorSpec{ + Name: "rabbitmq-cluster", + ControllerManager: operatorv1beta1.ContainerSpec{ + Env: []corev1.EnvVar{ + { + Name: "OPERATOR_SCOPE_NAMESPACE", + Value: "namespace1,namespace2", + }, + }, + }, + }, + initialEnvVars: nil, + expectedEnvVars: []corev1.EnvVar{ + { + Name: "OPERATOR_SCOPE_NAMESPACE", + Value: "namespace1,namespace2", + }, + }, + }, + { + name: "No custom env vars, keep defaults unchanged", + operatorSpec: operatorv1beta1.OperatorSpec{ + Name: "rabbitmq-cluster", + }, + initialEnvVars: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "info", + }, + }, + expectedEnvVars: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "info", + }, + }, + }, + { + name: "Merge custom env vars with defaults", + operatorSpec: operatorv1beta1.OperatorSpec{ + Name: "rabbitmq-cluster", + ControllerManager: operatorv1beta1.ContainerSpec{ + Env: []corev1.EnvVar{ + { + Name: "OPERATOR_SCOPE_NAMESPACE", + Value: "namespace1,namespace2", + }, + }, + }, + }, + initialEnvVars: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "info", + }, + }, + expectedEnvVars: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "info", + }, + { + Name: "OPERATOR_SCOPE_NAMESPACE", + Value: "namespace1,namespace2", + }, + }, + }, + { + name: "Override default env var", + operatorSpec: operatorv1beta1.OperatorSpec{ + Name: "rabbitmq-cluster", + ControllerManager: operatorv1beta1.ContainerSpec{ + Env: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "debug", + }, + }, + }, + }, + initialEnvVars: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "info", + }, + }, + expectedEnvVars: []corev1.EnvVar{ + { + Name: "LOG_LEVEL", + Value: "debug", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + op := &Operator{ + Name: tc.operatorSpec.Name, + Deployment: Deployment{ + Manager: Container{ + Env: tc.initialEnvVars, + }, + }, + } + + SetOverrides(tc.operatorSpec, op) + + if !reflect.DeepEqual(op.Deployment.Manager.Env, tc.expectedEnvVars) { + t.Errorf("wrong env vars after override:\n got: %+v\nwant: %+v", op.Deployment.Manager.Env, tc.expectedEnvVars) + } + }) + } +} + // --- Test for global defaults initialization --- func TestGlobalTolerationsDefaults(t *testing.T) {