From 8ee421f9ff7e775694533654a8ee01b33319fed2 Mon Sep 17 00:00:00 2001 From: Mitchell Murphy Date: Sun, 7 Jun 2026 20:54:27 -0400 Subject: [PATCH 1/3] feat(pooler): make connection pooler security contexts configurable Allow operators to override the connection pooler pod- and container-level securityContext via the OperatorConfiguration CRD (connection_pooler_pod_security_context and connection_pooler_security_context). When unset, the operator preserves its historical defaults (pod RunAsUser/RunAsGroup 100/101 and spilo FSGroup; container AllowPrivilegeEscalation=false), so existing deployments are unaffected. Useful for hardened images whose pgbouncer UID/GID differs from the default and for satisfying restricted Pod Security Standards. --- .../crds/operatorconfigurations.yaml | 6 + charts/postgres-operator/values.yaml | 18 +++ pkg/apis/acid.zalan.do/v1/crds.go | 8 ++ .../v1/operator_configuration_type.go | 5 + .../acid.zalan.do/v1/zz_generated.deepcopy.go | 10 ++ pkg/cluster/connection_pooler.go | 65 ++++++--- pkg/cluster/connection_pooler_test.go | 131 ++++++++++++++++++ pkg/controller/operator_config.go | 5 + pkg/util/config/config.go | 7 + 9 files changed, 238 insertions(+), 17 deletions(-) diff --git a/charts/postgres-operator/crds/operatorconfigurations.yaml b/charts/postgres-operator/crds/operatorconfigurations.yaml index 961b5b655..28b0584aa 100644 --- a/charts/postgres-operator/crds/operatorconfigurations.yaml +++ b/charts/postgres-operator/crds/operatorconfigurations.yaml @@ -851,6 +851,12 @@ spec: connection_pooler_default_memory_request: type: string pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$' + connection_pooler_pod_security_context: + type: object + x-kubernetes-preserve-unknown-fields: true + connection_pooler_security_context: + type: object + x-kubernetes-preserve-unknown-fields: true patroni: type: object properties: diff --git a/charts/postgres-operator/values.yaml b/charts/postgres-operator/values.yaml index 82e9ac342..a326c5a49 100644 --- a/charts/postgres-operator/values.yaml +++ b/charts/postgres-operator/values.yaml @@ -471,6 +471,24 @@ configConnectionPooler: connection_pooler_default_memory_request: 100Mi connection_pooler_default_cpu_limit: "1" connection_pooler_default_memory_limit: 100Mi + # Override the pooler pod- and container-level securityContext. Useful for hardened + # images whose pgbouncer user/UID differs from the operator default (100/101) and which + # own /etc/pgbouncer, or to satisfy restricted Pod Security Standards. When unset the + # operator keeps its defaults (pod RunAsUser/RunAsGroup 100/101, container + # allowPrivilegeEscalation=false). + # connection_pooler_pod_security_context: + # runAsUser: 100 + # runAsGroup: 101 + # runAsNonRoot: true + # fsGroup: 101 + # seccompProfile: + # type: RuntimeDefault + # connection_pooler_security_context: + # allowPrivilegeEscalation: false + # readOnlyRootFilesystem: true + # capabilities: + # drop: + # - ALL configPatroni: # enable Patroni DCS failsafe_mode feature diff --git a/pkg/apis/acid.zalan.do/v1/crds.go b/pkg/apis/acid.zalan.do/v1/crds.go index b6b58f072..addd1b4f6 100644 --- a/pkg/apis/acid.zalan.do/v1/crds.go +++ b/pkg/apis/acid.zalan.do/v1/crds.go @@ -1052,6 +1052,14 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{ "connection_pooler_user": { Type: "string", }, + "connection_pooler_pod_security_context": { + Type: "object", + XPreserveUnknownFields: util.True(), + }, + "connection_pooler_security_context": { + Type: "object", + XPreserveUnknownFields: util.True(), + }, }, }, }, diff --git a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go index 0087e5850..e37f091d0 100644 --- a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go +++ b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go @@ -220,6 +220,11 @@ type ConnectionPoolerConfiguration struct { DefaultMemoryRequest string `json:"connection_pooler_default_memory_request,omitempty"` DefaultCPULimit string `json:"connection_pooler_default_cpu_limit,omitempty"` DefaultMemoryLimit string `json:"connection_pooler_default_memory_limit,omitempty"` + // PodSecurityContext and SecurityContext allow overriding the pooler pod- and + // container-level security contexts (e.g. to match a hardened image's UID/GID or to + // satisfy restricted Pod Security Standards). When unset the operator keeps its defaults. + PodSecurityContext *v1.PodSecurityContext `json:"connection_pooler_pod_security_context,omitempty"` + SecurityContext *v1.SecurityContext `json:"connection_pooler_security_context,omitempty"` } // OperatorLogicalBackupConfiguration defines configuration for logical backup diff --git a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go index 3fdc31fa7..2f68755db 100644 --- a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go +++ b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go @@ -142,6 +142,16 @@ func (in *ConnectionPoolerConfiguration) DeepCopyInto(out *ConnectionPoolerConfi *out = new(int32) **out = **in } + if in.PodSecurityContext != nil { + in, out := &in.PodSecurityContext, &out.PodSecurityContext + *out = new(corev1.PodSecurityContext) + (*in).DeepCopyInto(*out) + } + if in.SecurityContext != nil { + in, out := &in.SecurityContext, &out.SecurityContext + *out = new(corev1.SecurityContext) + (*in).DeepCopyInto(*out) + } return } diff --git a/pkg/cluster/connection_pooler.go b/pkg/cluster/connection_pooler.go index 9f071068c..292c126c3 100644 --- a/pkg/cluster/connection_pooler.go +++ b/pkg/cluster/connection_pooler.go @@ -29,6 +29,51 @@ import ( var poolerRunAsUser = int64(100) var poolerRunAsGroup = int64(101) +// generateConnectionPoolerPodSecurityContext returns the pod-level securityContext for the +// connection pooler. When the operator configuration provides one it is used as the base +// (deep-copied); otherwise an empty context is used. The historical defaults (RunAsUser/RunAsGroup +// 100/101) and the spilo FSGroup are applied only for fields the configuration leaves unset, so +// behavior is unchanged when no override is configured. +func (c *Cluster) generateConnectionPoolerPodSecurityContext(spec *acidv1.PostgresSpec) *v1.PodSecurityContext { + var securityContext v1.PodSecurityContext + if c.OpConfig.ConnectionPooler.PodSecurityContext != nil { + securityContext = *c.OpConfig.ConnectionPooler.PodSecurityContext.DeepCopy() + } + + if securityContext.RunAsUser == nil { + securityContext.RunAsUser = &poolerRunAsUser + } + if securityContext.RunAsGroup == nil { + securityContext.RunAsGroup = &poolerRunAsGroup + } + + if securityContext.FSGroup == nil { + effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup + if spec.SpiloFSGroup != nil { + effectiveFSGroup = spec.SpiloFSGroup + } + if effectiveFSGroup != nil { + securityContext.FSGroup = effectiveFSGroup + } + } + + return &securityContext +} + +// generateConnectionPoolerContainerSecurityContext returns the container-level securityContext for +// the pooler. A configured context is used as the base (deep-copied); AllowPrivilegeEscalation +// defaults to false when left unset, preserving prior behavior. +func (c *Cluster) generateConnectionPoolerContainerSecurityContext() *v1.SecurityContext { + var securityContext v1.SecurityContext + if c.OpConfig.ConnectionPooler.SecurityContext != nil { + securityContext = *c.OpConfig.ConnectionPooler.SecurityContext.DeepCopy() + } + if securityContext.AllowPrivilegeEscalation == nil { + securityContext.AllowPrivilegeEscalation = util.False() + } + return &securityContext +} + // ConnectionPoolerObjects K8s objects that are belong to connection pooler type ConnectionPoolerObjects struct { AuthSecret *v1.Secret @@ -383,9 +428,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) ( }, }, }, - SecurityContext: &v1.SecurityContext{ - AllowPrivilegeEscalation: util.False(), - }, + SecurityContext: c.generateConnectionPoolerContainerSecurityContext(), } var poolerVolumes []v1.Volume @@ -444,19 +487,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) ( poolerContainer.Env = envVars poolerContainer.VolumeMounts = volumeMounts tolerationsSpec := tolerations(&spec.Tolerations, c.OpConfig.PodToleration) - securityContext := v1.PodSecurityContext{} - - // determine the User, Group and FSGroup for the pooler pod - securityContext.RunAsUser = &poolerRunAsUser - securityContext.RunAsGroup = &poolerRunAsGroup - - effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup - if spec.SpiloFSGroup != nil { - effectiveFSGroup = spec.SpiloFSGroup - } - if effectiveFSGroup != nil { - securityContext.FSGroup = effectiveFSGroup - } + securityContext := c.generateConnectionPoolerPodSecurityContext(spec) podTemplate := &v1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ @@ -469,7 +500,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) ( Containers: []v1.Container{poolerContainer}, Tolerations: tolerationsSpec, Volumes: poolerVolumes, - SecurityContext: &securityContext, + SecurityContext: securityContext, ServiceAccountName: c.OpConfig.PodServiceAccountName, }, } diff --git a/pkg/cluster/connection_pooler_test.go b/pkg/cluster/connection_pooler_test.go index 23213520f..1e4f96ebf 100644 --- a/pkg/cluster/connection_pooler_test.go +++ b/pkg/cluster/connection_pooler_test.go @@ -1064,6 +1064,137 @@ func TestPoolerTLS(t *testing.T) { assert.Contains(t, poolerContainer.Env, v1.EnvVar{Name: "CONNECTION_POOLER_CLIENT_CA_FILE", Value: "/tls/ca.crt"}) } +// poolerSecurityContextOpConfig builds a minimal OpConfig usable for exercising the +// connection pooler securityContext behavior. Callers tweak the returned config's +// ConnectionPooler / Resources before passing it to syncedPoolerDeployment. +func poolerSecurityContextOpConfig() config.Config { + return config.Config{ + PodManagementPolicy: "ordered_ready", + ProtectedRoles: []string{"admin"}, + Auth: config.Auth{ + SuperUsername: superUserName, + ReplicationUsername: replicationUserName, + }, + Resources: config.Resources{ + ClusterLabels: map[string]string{"application": "spilo"}, + ClusterNameLabel: "cluster-name", + DefaultCPURequest: "300m", + DefaultCPULimit: "300m", + DefaultMemoryRequest: "300Mi", + DefaultMemoryLimit: "300Mi", + PodRoleLabel: "spilo-role", + }, + ConnectionPooler: config.ConnectionPooler{ + ConnectionPoolerDefaultCPURequest: "100m", + ConnectionPoolerDefaultCPULimit: "100m", + ConnectionPoolerDefaultMemoryRequest: "100Mi", + ConnectionPoolerDefaultMemoryLimit: "100Mi", + }, + PodServiceAccountName: "postgres-pod", + } +} + +// syncedPoolerDeployment provisions a pooler with the given OpConfig and returns the +// resulting master pooler Deployment. +func syncedPoolerDeployment(t *testing.T, opConfig config.Config) *appsv1.Deployment { + t.Helper() + client, _ := newFakeK8sPoolerTestClient() + clusterName := "acid-test-cluster" + namespace := "default" + + pg := acidv1.Postgresql{ + ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: namespace}, + Spec: acidv1.PostgresSpec{ + TeamID: "myapp", NumberOfInstances: 1, + EnableConnectionPooler: util.True(), + Resources: &acidv1.Resources{ + ResourceRequests: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")}, + ResourceLimits: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")}, + }, + Volume: acidv1.Volume{Size: "1G"}, + }, + } + + cluster := New(Config{OpConfig: opConfig}, client, pg, logger, eventRecorder) + + _, err := cluster.createStatefulSet() + assert.NoError(t, err) + + cluster.ConnectionPooler = map[PostgresRole]*ConnectionPoolerObjects{ + Master: { + Name: cluster.connectionPoolerName(Master), + ClusterName: clusterName, + Namespace: namespace, + Role: Master, + }, + } + + _, err = cluster.syncConnectionPoolerWorker(nil, &pg, Master) + assert.NoError(t, err) + + deploy, err := client.Deployments(namespace).Get(context.TODO(), cluster.connectionPoolerName(Master), metav1.GetOptions{}) + assert.NoError(t, err) + return deploy +} + +func TestConnectionPoolerDefaultSecurityContext(t *testing.T) { + deploy := syncedPoolerDeployment(t, poolerSecurityContextOpConfig()) + + podSc := deploy.Spec.Template.Spec.SecurityContext + assert.NotNil(t, podSc, "pod securityContext should be set") + assert.Equal(t, int64(100), *podSc.RunAsUser, "defaults to RunAsUser 100 for backward compatibility") + assert.Equal(t, int64(101), *podSc.RunAsGroup, "defaults to RunAsGroup 101 for backward compatibility") + + containerSc := deploy.Spec.Template.Spec.Containers[constants.ConnectionPoolerContainer].SecurityContext + assert.NotNil(t, containerSc, "container securityContext should be set") + assert.Equal(t, false, *containerSc.AllowPrivilegeEscalation, "defaults AllowPrivilegeEscalation to false") +} + +func TestConnectionPoolerPodSecurityContextOverride(t *testing.T) { + runAsUser := int64(1000001) + runAsGroup := int64(1000001) + spiloFSGroup := int64(103) + + opConfig := poolerSecurityContextOpConfig() + // FSGroup comes from the spilo config and must still apply when the override leaves it nil. + opConfig.Resources.SpiloFSGroup = &spiloFSGroup + opConfig.ConnectionPooler.PodSecurityContext = &v1.PodSecurityContext{ + RunAsUser: &runAsUser, + RunAsGroup: &runAsGroup, + RunAsNonRoot: util.True(), + SeccompProfile: &v1.SeccompProfile{ + Type: v1.SeccompProfileTypeRuntimeDefault, + }, + } + + deploy := syncedPoolerDeployment(t, opConfig) + podSc := deploy.Spec.Template.Spec.SecurityContext + + assert.Equal(t, runAsUser, *podSc.RunAsUser, "honors configured RunAsUser") + assert.Equal(t, runAsGroup, *podSc.RunAsGroup, "honors configured RunAsGroup") + assert.Equal(t, true, *podSc.RunAsNonRoot, "honors configured RunAsNonRoot") + assert.Equal(t, v1.SeccompProfileTypeRuntimeDefault, podSc.SeccompProfile.Type, "honors configured SeccompProfile") + assert.Equal(t, spiloFSGroup, *podSc.FSGroup, "still applies spilo FSGroup when override leaves it nil") +} + +func TestConnectionPoolerContainerSecurityContextOverride(t *testing.T) { + opConfig := poolerSecurityContextOpConfig() + opConfig.ConnectionPooler.SecurityContext = &v1.SecurityContext{ + AllowPrivilegeEscalation: util.False(), + ReadOnlyRootFilesystem: util.True(), + Capabilities: &v1.Capabilities{ + Drop: []v1.Capability{"ALL"}, + }, + } + + deploy := syncedPoolerDeployment(t, opConfig) + containerSc := deploy.Spec.Template.Spec.Containers[constants.ConnectionPoolerContainer].SecurityContext + + assert.Equal(t, true, *containerSc.ReadOnlyRootFilesystem, "honors configured ReadOnlyRootFilesystem") + assert.Equal(t, false, *containerSc.AllowPrivilegeEscalation, "keeps AllowPrivilegeEscalation false") + assert.Equal(t, []v1.Capability{"ALL"}, containerSc.Capabilities.Drop, "honors dropped capabilities") +} + func TestConnectionPoolerServiceSpec(t *testing.T) { testName := "Test connection pooler service spec generation" var cluster = New( diff --git a/pkg/controller/operator_config.go b/pkg/controller/operator_config.go index e304c14a5..65e3161e7 100644 --- a/pkg/controller/operator_config.go +++ b/pkg/controller/operator_config.go @@ -291,5 +291,10 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur fromCRD.ConnectionPooler.MaxDBConnections, k8sutil.Int32ToPointer(constants.ConnectionPoolerMaxDBConnections)) + // Security contexts are nil unless explicitly configured; the cluster package falls back + // to the historical pooler defaults when they are not set. + result.ConnectionPooler.PodSecurityContext = fromCRD.ConnectionPooler.PodSecurityContext + result.ConnectionPooler.SecurityContext = fromCRD.ConnectionPooler.SecurityContext + return result } diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index a14022407..cad9ebb2c 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -163,6 +163,13 @@ type ConnectionPooler struct { ConnectionPoolerDefaultMemoryRequest string `name:"connection_pooler_default_memory_request"` ConnectionPoolerDefaultCPULimit string `name:"connection_pooler_default_cpu_limit"` ConnectionPoolerDefaultMemoryLimit string `name:"connection_pooler_default_memory_limit"` + // PodSecurityContext and SecurityContext let operators override the pooler + // pod- and container-level security contexts. They are populated only from the + // OperatorConfiguration CRD (name:"-" excludes them from the ConfigMap decoder, + // mirroring LivenessProbe). When nil, the operator falls back to the historical + // defaults (pod RunAsUser/RunAsGroup 100/101, container AllowPrivilegeEscalation=false). + PodSecurityContext *v1.PodSecurityContext `name:"-"` + SecurityContext *v1.SecurityContext `name:"-"` } // Config describes operator config From 1719f200ff939cf29e87adb658d7fcee800a5abd Mon Sep 17 00:00:00 2001 From: Mitchell Murphy Date: Sun, 7 Jun 2026 20:58:58 -0400 Subject: [PATCH 2/3] docs(pooler): documented pooler securityContext changes Signed-off-by: Mitchell Murphy --- docs/reference/operator_parameters.md | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/reference/operator_parameters.md b/docs/reference/operator_parameters.md index 332742a16..b594c1337 100644 --- a/docs/reference/operator_parameters.md +++ b/docs/reference/operator_parameters.md @@ -1094,3 +1094,48 @@ operator being able to provide some reasonable defaults. **connection_pooler_default_cpu_limit** **connection_pooler_default_memory_limit** Default resource configuration for connection pooler deployment. + +* **connection_pooler_pod_security_context** + Pod-level `securityContext` applied to the connection pooler deployment. The + value is a standard Kubernetes + [PodSecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#podsecuritycontext-v1-core) + object. Only fields you set are overridden; unset fields keep the operator + defaults (`runAsUser: 100`, `runAsGroup: 101`, and the cluster's `fsGroup`). + Settable **only** via the `OperatorConfiguration` CRD (not the ConfigMap). + Defaults to not set. + +* **connection_pooler_security_context** + Container-level `securityContext` applied to the pgbouncer container. The + value is a standard Kubernetes + [SecurityContext](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#securitycontext-v1-core) + object. Only fields you set are overridden; when `allowPrivilegeEscalation` + is unset it defaults to `false`. Settable **only** via the + `OperatorConfiguration` CRD (not the ConfigMap). Defaults to not set. + +These two parameters are useful when running a hardened pooler image whose +pgbouncer user/UID differs from the operator default (100/101), or to satisfy +restricted [Pod Security Standards](https://kubernetes.io/docs/concepts/security/pod-security-standards/). +Because they are object-valued they cannot be expressed in the flat operator +ConfigMap; configure them through the `OperatorConfiguration` CRD: + +```yaml +apiVersion: "acid.zalan.do/v1" +kind: OperatorConfiguration +metadata: + name: postgresql-operator-default-configuration +configuration: + connection_pooler: + connection_pooler_pod_security_context: + runAsUser: 100 + runAsGroup: 101 + runAsNonRoot: true + fsGroup: 101 + seccompProfile: + type: RuntimeDefault + connection_pooler_security_context: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL +``` From bd2495377643debcd0ee289e1ddd70696cd03578 Mon Sep 17 00:00:00 2001 From: Mitchell Murphy Date: Thu, 25 Jun 2026 17:21:23 -0400 Subject: [PATCH 3/3] Add maintenance_windows items schema to operatorconfiguration CRD --- Makefile | 1 + .../crds/operatorconfigurations.yaml | 3 +++ hack/adjust_operatorconfiguration_crd.sh | 22 +++++++++++++++++++ manifests/operatorconfiguration.crd.yaml | 3 +++ .../v1/operatorconfiguration.crd.yaml | 3 +++ 5 files changed, 32 insertions(+) create mode 100755 hack/adjust_operatorconfiguration_crd.sh diff --git a/Makefile b/Makefile index 3613c1044..fe11a4384 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,7 @@ $(GENERATED_CRDS): $(GENERATED) @sed -i -e 's/listKind: PostgresqlList/listKind: postgresqlList/' manifests/postgresql.crd.yaml @hack/adjust_postgresql_crd.sh @mv manifests/acid.zalan.do_operatorconfigurations.yaml manifests/operatorconfiguration.crd.yaml + @hack/adjust_operatorconfiguration_crd.sh @mv manifests/acid.zalan.do_postgresteams.yaml manifests/postgresteam.crd.yaml @cp manifests/postgresql.crd.yaml pkg/apis/acid.zalan.do/v1/postgresql.crd.yaml @cp manifests/operatorconfiguration.crd.yaml pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml diff --git a/charts/postgres-operator/crds/operatorconfigurations.yaml b/charts/postgres-operator/crds/operatorconfigurations.yaml index 790b9effc..423fd4a89 100644 --- a/charts/postgres-operator/crds/operatorconfigurations.yaml +++ b/charts/postgres-operator/crds/operatorconfigurations.yaml @@ -709,6 +709,9 @@ spec: type: integer type: object maintenance_windows: + items: + pattern: '^\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))-((2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))\ *$' + type: string type: array major_version_upgrade: description: MajorVersionUpgradeConfiguration defines how to execute diff --git a/hack/adjust_operatorconfiguration_crd.sh b/hack/adjust_operatorconfiguration_crd.sh new file mode 100755 index 000000000..61fb4ac03 --- /dev/null +++ b/hack/adjust_operatorconfiguration_crd.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Hack to adjust the generated operatorconfiguration CRD YAML file and add +# missing field settings which can not be expressed via kubebuilder markers. +# +# Injections: +# +# * type: string and pattern for the maintenance_windows items. +# The MaintenanceWindow type marshals to/from a string (see marshal.go) but +# the field is declared `+kubebuilder:validation:Schemaless` / +# `+kubebuilder:validation:Type=array`, so controller-gen emits a bare +# `type: array` without the required `items` schema. A structural CRD must +# specify `items` for every array, so the generated CRD is rejected with: +# spec.validation.openAPIV3Schema.properties[configuration] +# .properties[maintenance_windows].items: Required value: must be specified + +file="${1:-"manifests/operatorconfiguration.crd.yaml"}" + +sed -i '/^[[:space:]]*maintenance_windows:$/{ + # Capture the indentation + s/^\([[:space:]]*\)maintenance_windows:$/\1maintenance_windows:\n\1 items:\n\1 pattern: '\''^\\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))-((2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))\\ *$'\''\n\1 type: string/ +}' "$file" diff --git a/manifests/operatorconfiguration.crd.yaml b/manifests/operatorconfiguration.crd.yaml index 5f347f2ac..67487ba1c 100644 --- a/manifests/operatorconfiguration.crd.yaml +++ b/manifests/operatorconfiguration.crd.yaml @@ -704,6 +704,9 @@ spec: type: integer type: object maintenance_windows: + items: + pattern: '^\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))-((2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))\ *$' + type: string type: array major_version_upgrade: description: MajorVersionUpgradeConfiguration defines how to execute diff --git a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml index 5f347f2ac..67487ba1c 100644 --- a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml +++ b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml @@ -704,6 +704,9 @@ spec: type: integer type: object maintenance_windows: + items: + pattern: '^\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))-((2[0-3]|[01]?\d):([0-5]?\d)|(2[0-3]|[01]?\d):([0-5]?\d))\ *$' + type: string type: array major_version_upgrade: description: MajorVersionUpgradeConfiguration defines how to execute