diff --git a/build/crd/crunchy/generated/postgres-operator.crunchydata.com_postgresclusters.yaml b/build/crd/crunchy/generated/postgres-operator.crunchydata.com_postgresclusters.yaml index f13d01a96a..5c2f9295a5 100644 --- a/build/crd/crunchy/generated/postgres-operator.crunchydata.com_postgresclusters.yaml +++ b/build/crd/crunchy/generated/postgres-operator.crunchydata.com_postgresclusters.yaml @@ -39,6 +39,61 @@ spec: spec: description: PostgresClusterSpec defines the desired state of PostgresCluster properties: + authentication: + description: |- + Defines additional authentication rules for PostgreSQL host-based + authentication (pg_hba.conf). Rules added here are applied after any + mandatory rules and before the default scram-sha-256 fallback. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object backups: description: PostgreSQL backup configuration properties: @@ -7387,6 +7442,7 @@ spec: config: properties: files: + description: Files to mount under "/etc/postgres". items: description: |- Projection that may be projected along with other supported volume types. @@ -7828,6 +7884,55 @@ spec: type: object type: object type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' type: object customReplicationTLSSecret: description: |- diff --git a/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml b/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml index 8d64038662..01a0e8413a 100644 --- a/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml +++ b/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml @@ -56,6 +56,62 @@ spec: type: object spec: properties: + authentication: + description: |- + Defines custom pg_hba.conf authentication rules. Rules are evaluated + after mandatory operator rules and before the default scram-sha-256 + fallback. Use this together with spec.config.files to supply supporting + files such as an LDAP CA certificate. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object autoCreateUserSchema: description: |- Indicates whether schemas are automatically created for the user @@ -7579,6 +7635,505 @@ spec: && size(self.pgbackrest.repos) > 0) clusterServiceDNSSuffix: type: string + config: + description: |- + Configuration for PostgreSQL config files and server parameters. + Use spec.config.files to mount files (e.g. LDAP CA certificate) under + /etc/postgres, and spec.config.parameters to set postgresql.conf values. + properties: + files: + description: Files to mount under "/etc/postgres". + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root to write + the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name, namespace + and uid are supported.' + 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 + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path. Must + be utf-8 encoded. The first item of the relative + path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) 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 + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate + chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a + PodCertificateRequest to the named signer. Once the signer approves the + request and issues a certificate chain, Kubelet writes the key and + certificate chain to the pod filesystem. The pod does not start until + certificates have been issued for each podCertificate projected volume + source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated + by the signer using the PodCertificateRequest.Status.BeginRefreshAt + timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath + field, or separate files, indicated by the keyPath and + certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM + entry is the private key (in PKCS#8 format), and the remaining PEM + entries are the certificate chain issued by the signer (typically, + signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code + can read it atomically. If you use keyPath and certificateChainPath, + your application must make two separate file reads. If these coincide + with a certificate rotation, it is possible that the private key and leaf + certificate you read may not correspond to each other. Your application + will need to check for this condition, and re-read until they are + consistent. + + The named signer controls chooses the format of the certificate it + issues; consult the signer implementation's documentation to learn how to + use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. + The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private + key. + + The remaining blocks are CERTIFICATE blocks, containing the issued + certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single + atomic read that retrieves a consistent key and certificate chain. If you + project them to separate files, your application code will need to + additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", + "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the + certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it + generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver + will reject values shorter than 3600 (1 hour). The maximum allowable + value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any + lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 + seconds (1 hour). This constraint is enforced by kube-apiserver. + `kubernetes.io` signers will never issue certificates with a lifetime + longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs will be addressed + to this signer. + type: string + userAnnotations: + additionalProperties: + type: string + description: |- + userAnnotations allow pod authors to pass additional information to + the signer implementation. Kubernetes does not restrict or validate this + metadata in any way. + + These values are copied verbatim into the `spec.unverifiedUserAnnotations` field of + the PodCertificateRequest objects that Kubelet creates. + + Entries are subject to the same validation as object metadata annotations, + with the addition that all keys must be domain-prefixed. No restrictions + are placed on values, except an overall size limitation on the entire field. + + Signers should document the keys and values they support. Signers should + deny requests that contain keys they do not recognize. + type: object + required: + - keyType + - signerName + type: object + secret: + description: secret information about the secret data to + project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about the + serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' + type: object crVersion: description: |- Version of the operator. Update this to new version after operator diff --git a/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml b/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml index 85c24300ef..f2cc87840a 100644 --- a/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml +++ b/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml @@ -695,6 +695,62 @@ spec: type: object spec: properties: + authentication: + description: |- + Defines custom pg_hba.conf authentication rules. Rules are evaluated + after mandatory operator rules and before the default scram-sha-256 + fallback. Use this together with spec.config.files to supply supporting + files such as an LDAP CA certificate. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object autoCreateUserSchema: description: |- Indicates whether schemas are automatically created for the user @@ -8218,6 +8274,505 @@ spec: && size(self.pgbackrest.repos) > 0) clusterServiceDNSSuffix: type: string + config: + description: |- + Configuration for PostgreSQL config files and server parameters. + Use spec.config.files to mount files (e.g. LDAP CA certificate) under + /etc/postgres, and spec.config.parameters to set postgresql.conf values. + properties: + files: + description: Files to mount under "/etc/postgres". + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root to write + the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name, namespace + and uid are supported.' + 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 + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path. Must + be utf-8 encoded. The first item of the relative + path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) 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 + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate + chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a + PodCertificateRequest to the named signer. Once the signer approves the + request and issues a certificate chain, Kubelet writes the key and + certificate chain to the pod filesystem. The pod does not start until + certificates have been issued for each podCertificate projected volume + source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated + by the signer using the PodCertificateRequest.Status.BeginRefreshAt + timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath + field, or separate files, indicated by the keyPath and + certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM + entry is the private key (in PKCS#8 format), and the remaining PEM + entries are the certificate chain issued by the signer (typically, + signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code + can read it atomically. If you use keyPath and certificateChainPath, + your application must make two separate file reads. If these coincide + with a certificate rotation, it is possible that the private key and leaf + certificate you read may not correspond to each other. Your application + will need to check for this condition, and re-read until they are + consistent. + + The named signer controls chooses the format of the certificate it + issues; consult the signer implementation's documentation to learn how to + use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. + The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private + key. + + The remaining blocks are CERTIFICATE blocks, containing the issued + certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single + atomic read that retrieves a consistent key and certificate chain. If you + project them to separate files, your application code will need to + additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", + "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the + certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it + generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver + will reject values shorter than 3600 (1 hour). The maximum allowable + value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any + lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 + seconds (1 hour). This constraint is enforced by kube-apiserver. + `kubernetes.io` signers will never issue certificates with a lifetime + longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs will be addressed + to this signer. + type: string + userAnnotations: + additionalProperties: + type: string + description: |- + userAnnotations allow pod authors to pass additional information to + the signer implementation. Kubernetes does not restrict or validate this + metadata in any way. + + These values are copied verbatim into the `spec.unverifiedUserAnnotations` field of + the PodCertificateRequest objects that Kubelet creates. + + Entries are subject to the same validation as object metadata annotations, + with the addition that all keys must be domain-prefixed. No restrictions + are placed on values, except an overall size limitation on the entire field. + + Signers should document the keys and values they support. Signers should + deny requests that contain keys they do not recognize. + type: object + required: + - keyType + - signerName + type: object + secret: + description: secret information about the secret data to + project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about the + serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' + type: object crVersion: description: |- Version of the operator. Update this to new version after operator diff --git a/config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml b/config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml index ecb72637cd..0de56ada17 100644 --- a/config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml +++ b/config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml @@ -41,6 +41,61 @@ spec: spec: description: PostgresClusterSpec defines the desired state of PostgresCluster properties: + authentication: + description: |- + Defines additional authentication rules for PostgreSQL host-based + authentication (pg_hba.conf). Rules added here are applied after any + mandatory rules and before the default scram-sha-256 fallback. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object backups: description: PostgreSQL backup configuration properties: @@ -7377,6 +7432,7 @@ spec: config: properties: files: + description: Files to mount under "/etc/postgres". items: description: |- Projection that may be projected along with other supported volume types. @@ -7808,6 +7864,55 @@ spec: type: object type: object type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' type: object customReplicationTLSSecret: description: |- diff --git a/deploy/bundle.yaml b/deploy/bundle.yaml index 5a0a6fdf60..02a0d7dc4f 100644 --- a/deploy/bundle.yaml +++ b/deploy/bundle.yaml @@ -992,6 +992,62 @@ spec: type: object spec: properties: + authentication: + description: |- + Defines custom pg_hba.conf authentication rules. Rules are evaluated + after mandatory operator rules and before the default scram-sha-256 + fallback. Use this together with spec.config.files to supply supporting + files such as an LDAP CA certificate. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object autoCreateUserSchema: description: |- Indicates whether schemas are automatically created for the user @@ -8515,6 +8571,505 @@ spec: && size(self.pgbackrest.repos) > 0) clusterServiceDNSSuffix: type: string + config: + description: |- + Configuration for PostgreSQL config files and server parameters. + Use spec.config.files to mount files (e.g. LDAP CA certificate) under + /etc/postgres, and spec.config.parameters to set postgresql.conf values. + properties: + files: + description: Files to mount under "/etc/postgres". + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root to write + the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name, namespace + and uid are supported.' + 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 + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path. Must + be utf-8 encoded. The first item of the relative + path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) 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 + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate + chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a + PodCertificateRequest to the named signer. Once the signer approves the + request and issues a certificate chain, Kubelet writes the key and + certificate chain to the pod filesystem. The pod does not start until + certificates have been issued for each podCertificate projected volume + source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated + by the signer using the PodCertificateRequest.Status.BeginRefreshAt + timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath + field, or separate files, indicated by the keyPath and + certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM + entry is the private key (in PKCS#8 format), and the remaining PEM + entries are the certificate chain issued by the signer (typically, + signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code + can read it atomically. If you use keyPath and certificateChainPath, + your application must make two separate file reads. If these coincide + with a certificate rotation, it is possible that the private key and leaf + certificate you read may not correspond to each other. Your application + will need to check for this condition, and re-read until they are + consistent. + + The named signer controls chooses the format of the certificate it + issues; consult the signer implementation's documentation to learn how to + use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. + The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private + key. + + The remaining blocks are CERTIFICATE blocks, containing the issued + certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single + atomic read that retrieves a consistent key and certificate chain. If you + project them to separate files, your application code will need to + additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", + "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the + certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it + generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver + will reject values shorter than 3600 (1 hour). The maximum allowable + value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any + lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 + seconds (1 hour). This constraint is enforced by kube-apiserver. + `kubernetes.io` signers will never issue certificates with a lifetime + longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs will be addressed + to this signer. + type: string + userAnnotations: + additionalProperties: + type: string + description: |- + userAnnotations allow pod authors to pass additional information to + the signer implementation. Kubernetes does not restrict or validate this + metadata in any way. + + These values are copied verbatim into the `spec.unverifiedUserAnnotations` field of + the PodCertificateRequest objects that Kubelet creates. + + Entries are subject to the same validation as object metadata annotations, + with the addition that all keys must be domain-prefixed. No restrictions + are placed on values, except an overall size limitation on the entire field. + + Signers should document the keys and values they support. Signers should + deny requests that contain keys they do not recognize. + type: object + required: + - keyType + - signerName + type: object + secret: + description: secret information about the secret data to + project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about the + serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' + type: object crVersion: description: |- Version of the operator. Update this to new version after operator @@ -31083,6 +31638,61 @@ spec: spec: description: PostgresClusterSpec defines the desired state of PostgresCluster properties: + authentication: + description: |- + Defines additional authentication rules for PostgreSQL host-based + authentication (pg_hba.conf). Rules added here are applied after any + mandatory rules and before the default scram-sha-256 fallback. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object backups: description: PostgreSQL backup configuration properties: @@ -38419,6 +39029,7 @@ spec: config: properties: files: + description: Files to mount under "/etc/postgres". items: description: |- Projection that may be projected along with other supported volume types. @@ -38850,6 +39461,55 @@ spec: type: object type: object type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' type: object customReplicationTLSSecret: description: |- diff --git a/deploy/cr.yaml b/deploy/cr.yaml index 83024e12f8..59c90dbb1d 100644 --- a/deploy/cr.yaml +++ b/deploy/cr.yaml @@ -70,6 +70,14 @@ spec: # autoCreateUserSchema: true +# config: +# files: +# - secret: +# name: my-ldap-ca-cert +# items: +# - key: ca.crt +# path: ldap-ca.crt + # users: # - name: rhino # databases: diff --git a/deploy/crd.yaml b/deploy/crd.yaml index db049b8f5e..89bf989976 100644 --- a/deploy/crd.yaml +++ b/deploy/crd.yaml @@ -992,6 +992,62 @@ spec: type: object spec: properties: + authentication: + description: |- + Defines custom pg_hba.conf authentication rules. Rules are evaluated + after mandatory operator rules and before the default scram-sha-256 + fallback. Use this together with spec.config.files to supply supporting + files such as an LDAP CA certificate. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object autoCreateUserSchema: description: |- Indicates whether schemas are automatically created for the user @@ -8515,6 +8571,505 @@ spec: && size(self.pgbackrest.repos) > 0) clusterServiceDNSSuffix: type: string + config: + description: |- + Configuration for PostgreSQL config files and server parameters. + Use spec.config.files to mount files (e.g. LDAP CA certificate) under + /etc/postgres, and spec.config.parameters to set postgresql.conf values. + properties: + files: + description: Files to mount under "/etc/postgres". + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root to write + the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name, namespace + and uid are supported.' + 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 + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path. Must + be utf-8 encoded. The first item of the relative + path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) 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 + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate + chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a + PodCertificateRequest to the named signer. Once the signer approves the + request and issues a certificate chain, Kubelet writes the key and + certificate chain to the pod filesystem. The pod does not start until + certificates have been issued for each podCertificate projected volume + source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated + by the signer using the PodCertificateRequest.Status.BeginRefreshAt + timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath + field, or separate files, indicated by the keyPath and + certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM + entry is the private key (in PKCS#8 format), and the remaining PEM + entries are the certificate chain issued by the signer (typically, + signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code + can read it atomically. If you use keyPath and certificateChainPath, + your application must make two separate file reads. If these coincide + with a certificate rotation, it is possible that the private key and leaf + certificate you read may not correspond to each other. Your application + will need to check for this condition, and re-read until they are + consistent. + + The named signer controls chooses the format of the certificate it + issues; consult the signer implementation's documentation to learn how to + use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. + The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private + key. + + The remaining blocks are CERTIFICATE blocks, containing the issued + certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single + atomic read that retrieves a consistent key and certificate chain. If you + project them to separate files, your application code will need to + additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", + "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the + certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it + generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver + will reject values shorter than 3600 (1 hour). The maximum allowable + value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any + lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 + seconds (1 hour). This constraint is enforced by kube-apiserver. + `kubernetes.io` signers will never issue certificates with a lifetime + longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs will be addressed + to this signer. + type: string + userAnnotations: + additionalProperties: + type: string + description: |- + userAnnotations allow pod authors to pass additional information to + the signer implementation. Kubernetes does not restrict or validate this + metadata in any way. + + These values are copied verbatim into the `spec.unverifiedUserAnnotations` field of + the PodCertificateRequest objects that Kubelet creates. + + Entries are subject to the same validation as object metadata annotations, + with the addition that all keys must be domain-prefixed. No restrictions + are placed on values, except an overall size limitation on the entire field. + + Signers should document the keys and values they support. Signers should + deny requests that contain keys they do not recognize. + type: object + required: + - keyType + - signerName + type: object + secret: + description: secret information about the secret data to + project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about the + serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' + type: object crVersion: description: |- Version of the operator. Update this to new version after operator @@ -31083,6 +31638,61 @@ spec: spec: description: PostgresClusterSpec defines the desired state of PostgresCluster properties: + authentication: + description: |- + Defines additional authentication rules for PostgreSQL host-based + authentication (pg_hba.conf). Rules added here are applied after any + mandatory rules and before the default scram-sha-256 fallback. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object backups: description: PostgreSQL backup configuration properties: @@ -38419,6 +39029,7 @@ spec: config: properties: files: + description: Files to mount under "/etc/postgres". items: description: |- Projection that may be projected along with other supported volume types. @@ -38850,6 +39461,55 @@ spec: type: object type: object type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' type: object customReplicationTLSSecret: description: |- diff --git a/deploy/cw-bundle.yaml b/deploy/cw-bundle.yaml index 481ad556fc..7f0fe9b850 100644 --- a/deploy/cw-bundle.yaml +++ b/deploy/cw-bundle.yaml @@ -992,6 +992,62 @@ spec: type: object spec: properties: + authentication: + description: |- + Defines custom pg_hba.conf authentication rules. Rules are evaluated + after mandatory operator rules and before the default scram-sha-256 + fallback. Use this together with spec.config.files to supply supporting + files such as an LDAP CA certificate. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object autoCreateUserSchema: description: |- Indicates whether schemas are automatically created for the user @@ -8515,6 +8571,505 @@ spec: && size(self.pgbackrest.repos) > 0) clusterServiceDNSSuffix: type: string + config: + description: |- + Configuration for PostgreSQL config files and server parameters. + Use spec.config.files to mount files (e.g. LDAP CA certificate) under + /etc/postgres, and spec.config.parameters to set postgresql.conf values. + properties: + files: + description: Files to mount under "/etc/postgres". + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root to write + the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name, namespace + and uid are supported.' + 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 + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path. Must + be utf-8 encoded. The first item of the relative + path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) 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 + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podCertificate: + description: |- + Projects an auto-rotating credential bundle (private key and certificate + chain) that the pod can use either as a TLS client or server. + + Kubelet generates a private key and uses it to send a + PodCertificateRequest to the named signer. Once the signer approves the + request and issues a certificate chain, Kubelet writes the key and + certificate chain to the pod filesystem. The pod does not start until + certificates have been issued for each podCertificate projected volume + source in its spec. + + Kubelet will begin trying to rotate the certificate at the time indicated + by the signer using the PodCertificateRequest.Status.BeginRefreshAt + timestamp. + + Kubelet can write a single file, indicated by the credentialBundlePath + field, or separate files, indicated by the keyPath and + certificateChainPath fields. + + The credential bundle is a single file in PEM format. The first PEM + entry is the private key (in PKCS#8 format), and the remaining PEM + entries are the certificate chain issued by the signer (typically, + signers will return their certificate chain in leaf-to-root order). + + Prefer using the credential bundle format, since your application code + can read it atomically. If you use keyPath and certificateChainPath, + your application must make two separate file reads. If these coincide + with a certificate rotation, it is possible that the private key and leaf + certificate you read may not correspond to each other. Your application + will need to check for this condition, and re-read until they are + consistent. + + The named signer controls chooses the format of the certificate it + issues; consult the signer implementation's documentation to learn how to + use the certificates it issues. + properties: + certificateChainPath: + description: |- + Write the certificate chain at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + credentialBundlePath: + description: |- + Write the credential bundle at this path in the projected volume. + + The credential bundle is a single file that contains multiple PEM blocks. + The first PEM block is a PRIVATE KEY block, containing a PKCS#8 private + key. + + The remaining blocks are CERTIFICATE blocks, containing the issued + certificate chain from the signer (leaf and any intermediates). + + Using credentialBundlePath lets your Pod's application code make a single + atomic read that retrieves a consistent key and certificate chain. If you + project them to separate files, your application code will need to + additionally check that the leaf certificate was issued to the key. + type: string + keyPath: + description: |- + Write the key at this path in the projected volume. + + Most applications should use credentialBundlePath. When using keyPath + and certificateChainPath, your application needs to check that the key + and leaf certificate are consistent, because it is possible to read the + files mid-rotation. + type: string + keyType: + description: |- + The type of keypair Kubelet will generate for the pod. + + Valid values are "RSA3072", "RSA4096", "ECDSAP256", "ECDSAP384", + "ECDSAP521", and "ED25519". + type: string + maxExpirationSeconds: + description: |- + maxExpirationSeconds is the maximum lifetime permitted for the + certificate. + + Kubelet copies this value verbatim into the PodCertificateRequests it + generates for this projection. + + If omitted, kube-apiserver will set it to 86400(24 hours). kube-apiserver + will reject values shorter than 3600 (1 hour). The maximum allowable + value is 7862400 (91 days). + + The signer implementation is then free to issue a certificate with any + lifetime *shorter* than MaxExpirationSeconds, but no shorter than 3600 + seconds (1 hour). This constraint is enforced by kube-apiserver. + `kubernetes.io` signers will never issue certificates with a lifetime + longer than 24 hours. + format: int32 + type: integer + signerName: + description: Kubelet's generated CSRs will be addressed + to this signer. + type: string + userAnnotations: + additionalProperties: + type: string + description: |- + userAnnotations allow pod authors to pass additional information to + the signer implementation. Kubernetes does not restrict or validate this + metadata in any way. + + These values are copied verbatim into the `spec.unverifiedUserAnnotations` field of + the PodCertificateRequest objects that Kubelet creates. + + Entries are subject to the same validation as object metadata annotations, + with the addition that all keys must be domain-prefixed. No restrictions + are placed on values, except an overall size limitation on the entire field. + + Signers should document the keys and values they support. Signers should + deny requests that contain keys they do not recognize. + type: object + required: + - keyType + - signerName + type: object + secret: + description: secret information about the secret data to + project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + 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: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about the + serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' + type: object crVersion: description: |- Version of the operator. Update this to new version after operator @@ -31083,6 +31638,61 @@ spec: spec: description: PostgresClusterSpec defines the desired state of PostgresCluster properties: + authentication: + description: |- + Defines additional authentication rules for PostgreSQL host-based + authentication (pg_hba.conf). Rules added here are applied after any + mandatory rules and before the default scram-sha-256 fallback. + properties: + rules: + description: |- + Rules to include in pg_hba.conf. They are evaluated after mandatory + operator rules and before the default scram-sha-256 fallback. + items: + description: |- + PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either + the structured fields or the raw HBA line, not both. + properties: + connection: + description: 'Connection type: local, host, hostssl, hostnossl, + hostgssenc, hostnogssenc.' + type: string + databases: + description: Databases to match. An empty list matches all + databases. + items: + type: string + type: array + hba: + description: |- + A raw pg_hba.conf line. When non-empty, this line is used as-is and the + structured fields are ignored. + type: string + method: + description: Authentication method to use when a connection + matches this rule. + type: string + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Options for the authentication method (e.g. + ldapserver, ldapport). + type: object + users: + description: Users to match. An empty list matches all users. + items: + type: string + type: array + required: + - connection + - method + type: object + type: array + x-kubernetes-list-type: atomic + type: object backups: description: PostgreSQL backup configuration properties: @@ -38419,6 +39029,7 @@ spec: config: properties: files: + description: Files to mount under "/etc/postgres". items: description: |- Projection that may be projected along with other supported volume types. @@ -38850,6 +39461,55 @@ spec: type: object type: object type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") + && !(k == ''ssl_groups'' || k == ''ssl_ecdh_curve''))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' type: object customReplicationTLSSecret: description: |- diff --git a/e2e-tests/run-pr.csv b/e2e-tests/run-pr.csv index 2ff09553d2..4aa6aae837 100644 --- a/e2e-tests/run-pr.csv +++ b/e2e-tests/run-pr.csv @@ -10,6 +10,8 @@ dynamic-configuration finalizers init-deploy huge-pages +ldap +ldap-tls monitoring monitoring-pmm3 one-pod diff --git a/e2e-tests/run-release.csv b/e2e-tests/run-release.csv index c995b15b86..dec38a6bf1 100644 --- a/e2e-tests/run-release.csv +++ b/e2e-tests/run-release.csv @@ -10,6 +10,8 @@ dynamic-configuration finalizers init-deploy huge-pages +ldap +ldap-tls major-upgrade monitoring monitoring-pmm3 diff --git a/e2e-tests/tests/ldap-tls/00-assert.yaml b/e2e-tests/tests/ldap-tls/00-assert.yaml new file mode 100644 index 0000000000..ae5a062d84 --- /dev/null +++ b/e2e-tests/tests/ldap-tls/00-assert.yaml @@ -0,0 +1,24 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 120 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: perconapgclusters.pgv2.percona.com +spec: + group: pgv2.percona.com + names: + kind: PerconaPGCluster + listKind: PerconaPGClusterList + plural: perconapgclusters + singular: perconapgcluster + scope: Namespaced +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: check-operator-deploy-status +timeout: 120 +commands: + - script: kubectl assert exist-enhanced deployment percona-postgresql-operator -n ${OPERATOR_NS:-$NAMESPACE} --field-selector status.readyReplicas=1 diff --git a/e2e-tests/tests/ldap-tls/00-deploy-operator.yaml b/e2e-tests/tests/ldap-tls/00-deploy-operator.yaml new file mode 100644 index 0000000000..7826f3b2b5 --- /dev/null +++ b/e2e-tests/tests/ldap-tls/00-deploy-operator.yaml @@ -0,0 +1,14 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +timeout: 10 +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + init_temp_dir # do this only in the first TestStep + + deploy_operator + deploy_client + deploy_cert_manager diff --git a/e2e-tests/tests/ldap-tls/01-assert.yaml b/e2e-tests/tests/ldap-tls/01-assert.yaml new file mode 100644 index 0000000000..ff0067259d --- /dev/null +++ b/e2e-tests/tests/ldap-tls/01-assert.yaml @@ -0,0 +1,9 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +# Wait for cert-manager to issue the certificate and populate the ldap-ca Secret. +timeout: 120 +--- +apiVersion: v1 +kind: Secret +metadata: + name: ldap-ca \ No newline at end of file diff --git a/e2e-tests/tests/ldap-tls/01-openldap-tls.yaml b/e2e-tests/tests/ldap-tls/01-openldap-tls.yaml new file mode 100644 index 0000000000..d77fa1a4c9 --- /dev/null +++ b/e2e-tests/tests/ldap-tls/01-openldap-tls.yaml @@ -0,0 +1,4 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: + - files/openldap-tls.yaml diff --git a/e2e-tests/tests/ldap-tls/02-assert.yaml b/e2e-tests/tests/ldap-tls/02-assert.yaml new file mode 100644 index 0000000000..8cacaad372 --- /dev/null +++ b/e2e-tests/tests/ldap-tls/02-assert.yaml @@ -0,0 +1,14 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +# The ldap-ca Secret is guaranteed to exist before this step, so the pod starts +# without a FailedMount delay. Allow time for readinessProbe.initialDelaySeconds +# (120s) plus OpenLDAP startup. +timeout: 300 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openldap-tls +status: + availableReplicas: 1 + readyReplicas: 1 \ No newline at end of file diff --git a/e2e-tests/tests/ldap-tls/02-openldap-tls.yaml b/e2e-tests/tests/ldap-tls/02-openldap-tls.yaml new file mode 100644 index 0000000000..787d6d62c3 --- /dev/null +++ b/e2e-tests/tests/ldap-tls/02-openldap-tls.yaml @@ -0,0 +1,4 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: + - files/openldap-tls-deploy.yaml \ No newline at end of file diff --git a/e2e-tests/tests/ldap-tls/03-assert.yaml b/e2e-tests/tests/ldap-tls/03-assert.yaml new file mode 100644 index 0000000000..b14147472a --- /dev/null +++ b/e2e-tests/tests/ldap-tls/03-assert.yaml @@ -0,0 +1,19 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: ldap-tls +status: + instances: + - name: instance1 + readyReplicas: 3 + replicas: 3 + updatedReplicas: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: ldap-tls-primary diff --git a/e2e-tests/tests/ldap-tls/03-cluster.yaml b/e2e-tests/tests/ldap-tls/03-cluster.yaml new file mode 100644 index 0000000000..5ae2e7999c --- /dev/null +++ b/e2e-tests/tests/ldap-tls/03-cluster.yaml @@ -0,0 +1,17 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +timeout: 10 +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + + get_cr \ + | yq eval ' + .spec.users += [{"name":"percona","databases":["percona"]}] | + .spec.authentication.rules = [{"connection":"host","method":"ldap","users":["percona"],"options":{"ldapscheme":"ldaps","ldapserver":"openldap-tls","ldapport":636,"ldapprefix":"uid=","ldapsuffix":",ou=perconadba,dc=ldap,dc=local"}}] | + .spec.config.files = [{"secret":{"name":"ldap-ca","items":[{"key":"ca.crt","path":"ldap/ca.crt"}]}}] + ' - \ + | kubectl -n "${NAMESPACE}" apply -f - diff --git a/e2e-tests/tests/ldap-tls/04-verify-ldaps-auth.yaml b/e2e-tests/tests/ldap-tls/04-verify-ldaps-auth.yaml new file mode 100644 index 0000000000..672bfb2165 --- /dev/null +++ b/e2e-tests/tests/ldap-tls/04-verify-ldaps-auth.yaml @@ -0,0 +1,63 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + set -e + + PRIMARY=$( + kubectl get pod --namespace "${NAMESPACE}" \ + --output name --selector \ + 'postgres-operator.crunchydata.com/cluster=ldap-tls,postgres-operator.crunchydata.com/role=primary' \ + | head -1 + ) + + HBA=$(kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -c database \ + -- psql -tAc "SELECT pg_read_file('pg_hba.conf');" 2>&1) + + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + contains "${HBA}" "ldapscheme" || { + echo >&2 'pg_hba.conf does not contain an ldapscheme option (expected ldapscheme=ldaps)' + exit 1 + } + + ## verify ldap directories + for i in $(seq 12); do + if kubectl exec --namespace "${NAMESPACE}" deployment/openldap-tls -c ldap-setup \ + -- ldapsearch -x -H ldap://localhost:389 \ + -D "cn=admin,dc=ldap,dc=local" -w adminpassword \ + -b "uid=percona,ou=perconadba,dc=ldap,dc=local" 2>&1 \ + | grep -q "uid: percona"; then + echo "LDAP entry for percona found" + break + fi + echo "Attempt ${i}: LDAP entry not yet available, waiting..." + sleep 10 + done + + # verify LDAPS auth — PostgreSQL connects to OpenLDAP over ldaps:// + # (TLS between Postgres and the LDAP server, validated via LDAPTLS_CACERT). + # PGSSLMODE=disable controls only the psql→Postgres connection, not the + # Postgres→LDAP connection. + result="" + for i in $(seq 6); do + result=$(kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -c database \ + -- bash -c 'PGPASSWORD=mysecretpassword PGSSLMODE=disable \ + psql -h 127.0.0.1 -U percona -d percona -tAc "SELECT current_user;" 2>&1') && break + echo "Attempt ${i} failed: ${result}" + sleep 10 + done + + [[ "${result}" == "percona" ]] || { + echo >&2 "Expected current_user='percona', got: ${result}" + exit 1 + } + + # verify wrong password is rejected + if bad_result=$(kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -c database \ + -- bash -c 'PGPASSWORD=wrongpassword PGSSLMODE=disable \ + psql -h 127.0.0.1 -U percona -d percona -tAc "SELECT current_user;" 2>&1'); then + echo >&2 "Expected authentication failure with wrong password, but login succeeded: ${bad_result}" + exit 1 + fi + echo "Wrong password correctly rejected" diff --git a/e2e-tests/tests/ldap-tls/files/openldap-tls-deploy.yaml b/e2e-tests/tests/ldap-tls/files/openldap-tls-deploy.yaml new file mode 100644 index 0000000000..2d63805b93 --- /dev/null +++ b/e2e-tests/tests/ldap-tls/files/openldap-tls-deploy.yaml @@ -0,0 +1,145 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openldap-tls + labels: + app.kubernetes.io/name: openldap-tls +spec: + selector: + matchLabels: + app.kubernetes.io/name: openldap-tls + replicas: 1 + template: + metadata: + labels: + app.kubernetes.io/name: openldap-tls + spec: + containers: + - name: openldap + image: osixia/openldap:latest + imagePullPolicy: Always + args: ["--copy-service"] + env: + - name: LDAP_DOMAIN + value: "ldap.local" + - name: LDAP_ORGANISATION + value: "ldap.local" + - name: LDAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + key: adminpassword + name: openldap-tls-admin + - name: LDAP_CONFIG_PASSWORD + valueFrom: + secretKeyRef: + key: adminpassword + name: openldap-tls-admin + - name: LDAP_READONLY_USER + value: "true" + - name: LDAP_READONLY_USER_USERNAME + value: "readonly" + - name: LDAP_READONLY_USER_PASSWORD + value: "readonlypass" + - name: CONTAINER_LOG_LEVEL + value: "info" + - name: LDAP_TLS + value: "true" + - name: LDAP_TLS_CRT_FILENAME + value: "tls.crt" + - name: LDAP_TLS_KEY_FILENAME + value: "tls.key" + - name: LDAP_TLS_CA_CRT_FILENAME + value: "ca.crt" + # Do not require a client certificate from connecting peers. + - name: LDAP_TLS_VERIFY_CLIENT + value: "never" + # Keep plain LDAP on 389 available so the ldap-setup sidecar can + # run ldapadd without needing TLS. + - name: LDAP_TLS_ENFORCE + value: "false" + ports: + - name: tcp-ldap + containerPort: 389 + - name: tls-ldap + containerPort: 636 + volumeMounts: + - name: ldap-ca + mountPath: /container/service/slapd/assets/certs + readinessProbe: + tcpSocket: + port: 636 + initialDelaySeconds: 120 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + tcpSocket: + port: 636 + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + - name: ldap-setup + image: osixia/openldap:latest + imagePullPolicy: Always + command: ["/bin/bash"] + args: + - -c + - | + echo "Waiting for LDAP server to be ready..." + sleep 60 + + echo "Adding LDAP structure..." + ldapadd -x -H ldap://localhost:389 -D "cn=admin,dc=ldap,dc=local" -w adminpassword <&1) + + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + contains "${HBA}" "ldap" || { + echo >&2 'pg_hba.conf does not contain an ldap rule' + exit 1 + } + + ## verify ldap directories + for i in $(seq 12); do + if kubectl exec --namespace "${NAMESPACE}" deployment/openldap -c ldap-setup \ + -- ldapsearch -x -H ldap://localhost:389 \ + -D "cn=admin,dc=ldap,dc=local" -w adminpassword \ + -b "uid=percona,ou=perconadba,dc=ldap,dc=local" 2>&1 \ + | grep -q "uid: percona"; then + echo "LDAP entry for percona found" + break + fi + echo "Attempt ${i}: LDAP entry not yet available, waiting..." + sleep 10 + done + + # verify LDAP auth + result="" + for i in $(seq 6); do + result=$(kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -c database \ + -- bash -c 'PGPASSWORD=mysecretpassword PGSSLMODE=disable \ + psql -h 127.0.0.1 -U percona -d percona -tAc "SELECT current_user;" 2>&1') && break + echo "Attempt ${i} failed: ${result}" + sleep 10 + done + + [[ "${result}" == "percona" ]] || { + echo >&2 "Expected current_user='percona', got: ${result}" + exit 1 + } + + # verify wrong password is rejected + if bad_result=$(kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -c database \ + -- bash -c 'PGPASSWORD=wrongpassword PGSSLMODE=disable \ + psql -h 127.0.0.1 -U percona -d percona -tAc "SELECT current_user;" 2>&1'); then + echo >&2 "Expected authentication failure with wrong password, but login succeeded: ${bad_result}" + exit 1 + fi + echo "Wrong password correctly rejected" diff --git a/e2e-tests/tests/ldap/files/cluster.yaml b/e2e-tests/tests/ldap/files/cluster.yaml new file mode 100644 index 0000000000..092ad03614 --- /dev/null +++ b/e2e-tests/tests/ldap/files/cluster.yaml @@ -0,0 +1,23 @@ +apiVersion: pgv2.percona.com/v2 +kind: PerconaPGCluster +metadata: + name: ldap +spec: + # postgresVersion, images, backups, etc. are injected by get_cr() from deploy/cr.yaml + users: + - name: percona + databases: + - percona + # LDAP simple-bind: constructs DN as uid={username},ou=perconadba,dc=ldap,dc=local + # and binds with the user's password against the openldap Service (port 389). + authentication: + rules: + - connection: host + method: ldap + users: + - percona + options: + ldapserver: openldap + ldapport: 389 + ldapprefix: "uid=" + ldapsuffix: ",ou=perconadba,dc=ldap,dc=local" diff --git a/e2e-tests/tests/ldap/files/openldap.yaml b/e2e-tests/tests/ldap/files/openldap.yaml new file mode 100644 index 0000000000..274d4d830d --- /dev/null +++ b/e2e-tests/tests/ldap/files/openldap.yaml @@ -0,0 +1,126 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: openldap +type: Opaque +stringData: + adminpassword: adminpassword +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openldap + labels: + app.kubernetes.io/name: openldap +spec: + selector: + matchLabels: + app.kubernetes.io/name: openldap + replicas: 1 + template: + metadata: + labels: + app.kubernetes.io/name: openldap + spec: + containers: + - name: openldap + image: osixia/openldap:latest + imagePullPolicy: Always + args: ["--copy-service"] + env: + - name: LDAP_DOMAIN + value: "ldap.local" + - name: LDAP_ORGANISATION + value: "ldap.local" + - name: LDAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + key: adminpassword + name: openldap + - name: LDAP_CONFIG_PASSWORD + valueFrom: + secretKeyRef: + key: adminpassword + name: openldap + - name: LDAP_READONLY_USER + value: "true" + - name: LDAP_READONLY_USER_USERNAME + value: "readonly" + - name: LDAP_READONLY_USER_PASSWORD + value: "readonlypass" + - name: CONTAINER_LOG_LEVEL + value: "info" + ports: + - name: tcp-ldap + containerPort: 389 + readinessProbe: + tcpSocket: + port: 389 + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 5 + livenessProbe: + tcpSocket: + port: 389 + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + - name: ldap-setup + image: osixia/openldap:latest + imagePullPolicy: Always + command: ["/bin/bash"] + args: + - -c + - | + echo "Waiting for LDAP server to be ready..." + sleep 60 + + echo "Adding LDAP structure..." + ldapadd -x -H ldap://localhost:389 -D "cn=admin,dc=ldap,dc=local" -w adminpassword < 0 { + pgHBAs.Custom = append(pgHBAs.Custom, rule.HBA) + } else { + if hba := r.generatePostgresHBA(&rule.PostgresHBARule); hba != nil { + pgHBAs.Custom = append(pgHBAs.Custom, hba.String()) + } + } + } + } + // K8SPG-554 if cluster.Spec.TLSOnly { for i := range pgHBAs.Mandatory { diff --git a/internal/controller/postgrescluster/postgres.go b/internal/controller/postgrescluster/postgres.go index 792dbc0b71..0bfb080f59 100644 --- a/internal/controller/postgrescluster/postgres.go +++ b/internal/controller/postgrescluster/postgres.go @@ -44,6 +44,43 @@ import ( "github.com/percona/percona-postgresql-operator/v2/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) +// generatePostgresHBA converts one API rule into a structured HBA rule that +// safely formats its values. +func (*Reconciler) generatePostgresHBA(spec *v1beta1.PostgresHBARule) *postgres.HostBasedAuthentication { + if spec == nil { + return nil + } + + result := postgres.NewHBA() + result.Origin(spec.Connection) + + // The "password" method is not recommended. More likely, the user wants to + // use passwords generally. The "scram-sha-256" method is the preferred way + // to do that. + // - https://www.postgresql.org/docs/current/auth-password.html + if spec.Method == "password" { + result.Method("scram-sha-256") + } else { + result.Method(spec.Method) + } + + if len(spec.Databases) > 0 { + result.Databases(spec.Databases[0], spec.Databases[1:]...) + } + if len(spec.Users) > 0 { + result.Users(spec.Users[0], spec.Users[1:]...) + } + if len(spec.Options) > 0 { + opts := make(map[string]string, len(spec.Options)) + for k, v := range spec.Options { + opts[k] = v.String() + } + result.Options(opts) + } + + return result +} + // generatePostgresUserSecret returns a Secret containing a password and // connection details for the first database in spec. When existing is nil or // lacks a password or verifier, a new password and verifier are generated. diff --git a/internal/patroni/config.go b/internal/patroni/config.go index b7431591a5..f0a71f9dc6 100644 --- a/internal/patroni/config.go +++ b/internal/patroni/config.go @@ -253,6 +253,9 @@ func DynamicConfiguration( for i := range pgHBAs.Mandatory { hba = append(hba, pgHBAs.Mandatory[i].String()) } + // Include custom authentication rules from spec.authentication.rules. + // These are evaluated before any rules in Patroni's dynamic configuration. + hba = append(hba, pgHBAs.Custom...) if section, ok := postgresql["pg_hba"].([]any); ok { for i := range section { // any pg_hba values that are not strings will be skipped diff --git a/internal/pgbackrest/reconcile.go b/internal/pgbackrest/reconcile.go index ce86813c6e..20f11e477c 100644 --- a/internal/pgbackrest/reconcile.go +++ b/internal/pgbackrest/reconcile.go @@ -216,7 +216,7 @@ func AddConfigToRestorePod( } // mount any provided configuration files to the restore Job Pod - if len(cluster.Spec.Config.Files) != 0 { + if cluster.Spec.Config != nil && len(cluster.Spec.Config.Files) != 0 { additionalConfigVolumeMount := postgres.AdditionalConfigVolumeMount() additionalConfigVolume := corev1.Volume{Name: additionalConfigVolumeMount.Name} additionalConfigVolume.Projected = &corev1.ProjectedVolumeSource{ diff --git a/internal/pgbackrest/reconcile_test.go b/internal/pgbackrest/reconcile_test.go index fc5eee5ba6..2caf8f59cf 100644 --- a/internal/pgbackrest/reconcile_test.go +++ b/internal/pgbackrest/reconcile_test.go @@ -531,6 +531,7 @@ func TestAddConfigToRestorePod(t *testing.T) { custom.Name = "custom-configmap-files" cluster := cluster.DeepCopy() + cluster.Spec.Config = &v1beta1.PostgresConfigSpec{} cluster.Spec.Config.Files = []corev1.VolumeProjection{ {ConfigMap: &custom}, } diff --git a/internal/pgbouncer/postgres.go b/internal/pgbouncer/postgres.go index 6e3ccd9ed7..9aa27475ff 100644 --- a/internal/pgbouncer/postgres.go +++ b/internal/pgbouncer/postgres.go @@ -226,7 +226,7 @@ func postgresqlHBAs() []*postgres.HostBasedAuthentication { // - https://www.postgresql.org/docs/current/auth-password.html return []*postgres.HostBasedAuthentication{ - postgres.NewHBA().User(postgresqlUser).TLS().Method("scram-sha-256"), - postgres.NewHBA().User(postgresqlUser).TCP().Method("reject"), + postgres.NewHBA().Users(postgresqlUser).TLS().Method("scram-sha-256"), + postgres.NewHBA().Users(postgresqlUser).TCP().Method("reject"), } } diff --git a/internal/pgmonitor/postgres.go b/internal/pgmonitor/postgres.go index 7b34f80bf3..e372434b0b 100644 --- a/internal/pgmonitor/postgres.go +++ b/internal/pgmonitor/postgres.go @@ -25,9 +25,9 @@ func PostgreSQLHBAs(inCluster *v1beta1.PostgresCluster, outHBAs *postgres.HBAs) if ExporterEnabled(inCluster) { // Limit the monitoring user to local connections using SCRAM. outHBAs.Mandatory = append(outHBAs.Mandatory, - postgres.NewHBA().TCP().User(MonitoringUser).Method("scram-sha-256").Network("127.0.0.0/8"), - postgres.NewHBA().TCP().User(MonitoringUser).Method("scram-sha-256").Network("::1/128"), - postgres.NewHBA().TCP().User(MonitoringUser).Method("reject")) + postgres.NewHBA().TCP().Users(MonitoringUser).Method("scram-sha-256").Network("127.0.0.0/8"), + postgres.NewHBA().TCP().Users(MonitoringUser).Method("scram-sha-256").Network("::1/128"), + postgres.NewHBA().TCP().Users(MonitoringUser).Method("reject")) } } diff --git a/internal/pmm/hba.go b/internal/pmm/hba.go index ff9838c435..ccc17e1c76 100644 --- a/internal/pmm/hba.go +++ b/internal/pmm/hba.go @@ -15,7 +15,7 @@ const ( func PostgreSQLHBAs(inCluster *v1beta1.PostgresCluster, outHBAs *postgres.HBAs) { // Limit the monitoring user to local connections using SCRAM. outHBAs.Mandatory = append(outHBAs.Mandatory, - postgres.NewHBA().TCP().User(MonitoringUser).Method("scram-sha-256").Network("127.0.0.0/8"), - postgres.NewHBA().TCP().User(MonitoringUser).Method("scram-sha-256").Network("::1/128"), - postgres.NewHBA().TCP().User(MonitoringUser).Method("reject")) + postgres.NewHBA().TCP().Users(MonitoringUser).Method("scram-sha-256").Network("127.0.0.0/8"), + postgres.NewHBA().TCP().Users(MonitoringUser).Method("scram-sha-256").Network("::1/128"), + postgres.NewHBA().TCP().Users(MonitoringUser).Method("reject")) } diff --git a/internal/postgres/config.go b/internal/postgres/config.go index feda6d8aa1..74d16ec2d7 100644 --- a/internal/postgres/config.go +++ b/internal/postgres/config.go @@ -81,6 +81,9 @@ safelink() ( // configMountPath is where to mount additional config files configMountPath = "/etc/postgres" + + // tmpMountPath is where to mount the temporary volume. + tmpMountPath = "/pgtmp" ) // ConfigDirectory returns the absolute path to $PGDATA for cluster. diff --git a/internal/postgres/hba.go b/internal/postgres/hba.go index e245f81f4a..bcd9e18366 100644 --- a/internal/postgres/hba.go +++ b/internal/postgres/hba.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2026 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,37 +6,48 @@ package postgres import ( "fmt" + "maps" + "regexp" + "slices" "strings" ) // NewHBAs returns HostBasedAuthentication records required by this package. func NewHBAs() HBAs { return HBAs{ + Custom: nil, Mandatory: []*HostBasedAuthentication{ // The "postgres" superuser must always be able to connect locally. - NewHBA().Local().User("postgres").Method("peer"), + NewHBA().Local().Users("postgres").Method("peer"), // The replication user must always connect over TLS using certificate // authentication. Patroni also connects to the "postgres" database // when calling `pg_rewind`. // - https://www.postgresql.org/docs/current/warm-standby.html#STREAMING-REPLICATION-AUTHENTICATION - NewHBA().TLS().User(ReplicationUser).Method("cert").Replication(), - NewHBA().TLS().User(ReplicationUser).Method("cert").Database("postgres"), - NewHBA().TCP().User(ReplicationUser).Method("reject"), + NewHBA().TLS().Users(ReplicationUser).Method("cert").Replication(), + NewHBA().TLS().Users(ReplicationUser).Method("cert").Databases("postgres"), + NewHBA().TCP().Users(ReplicationUser).Method("reject"), }, Default: []*HostBasedAuthentication{ - // Allow TLS connections to any database using passwords. The "md5" - // authentication method automatically verifies passwords encrypted - // using either MD5 or SCRAM-SHA-256. + // Allow TLS connections to any database using passwords. Passwords are + // hashed and stored using SCRAM-SHA-256 by default. Since PostgreSQL 10, + // the "scram-sha-256" method is the preferred way to use those passwords. // - https://www.postgresql.org/docs/current/auth-password.html - NewHBA().TLS().Method("md5"), + NewHBA().TLS().Method("scram-sha-256"), }, } } // HBAs is a pairing of HostBasedAuthentication records. -type HBAs struct{ Mandatory, Default []*HostBasedAuthentication } +type HBAs struct { + // Custom holds additional pg_hba.conf lines to be inserted after Mandatory + // and before any rules from Patroni's dynamic configuration. When non-empty + // these lines suppress the Default rules. + Custom []string + Mandatory []*HostBasedAuthentication + Default []*HostBasedAuthentication +} // HostBasedAuthentication represents a single record for pg_hba.conf. // - https://www.postgresql.org/docs/current/auth-pg-hba-conf.html @@ -49,10 +60,51 @@ func NewHBA() *HostBasedAuthentication { return new(HostBasedAuthentication).AllDatabases().AllNetworks().AllUsers() } +// hbaRegexSpecialCharacters matches a superset of the special characters in +// PostgreSQL [regular expressions] for: +// +// - [HostBasedAuthentication.quoteDatabase] +// - [HostBasedAuthentication.quoteUser] +// +// [regular expressions]: https://www.postgresql.org/docs/current/functions-matching.html#POSIX-SYNTAX-DETAILS +var hbaRegexSpecialCharacters = regexp.MustCompile(`[^\pL\pN_]`) + func (*HostBasedAuthentication) quote(value string) string { return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` } +func (hba *HostBasedAuthentication) quoteDatabase(name string) string { + // Since PostgreSQL 16, a quoted string beginning with slash U+002F is + // interpreted as a regular expression. Express these names as a Postgres + // regex that exactly matches the entire name. + if len(name) > 0 && name[0] == '/' { + name = "/^" + + hbaRegexSpecialCharacters.ReplaceAllStringFunc(name, + func(match string) string { return "[" + match + "]" }) + + "$" + } + + // Quotes indicate the value is NOT a keyword (all, sameuser, etc.) + // and NOT to be expanded as a filename (at sign U+0040). + return hba.quote(name) +} + +func (hba *HostBasedAuthentication) quoteUser(name string) string { + // Since PostgreSQL 16, a quoted string beginning with slash U+002F is + // interpreted as a regular expression. Express these names as a Postgres + // regex that exactly matches the entire name. + if len(name) > 0 && name[0] == '/' { + name = "/^" + + hbaRegexSpecialCharacters.ReplaceAllStringFunc(name, + func(match string) string { return "[" + match + "]" }) + + "$" + } + + // Quotes indicate the value is NOT a keyword (all), NOT a group (plus U+002B), + // and NOT to be expanded as a filename (at sign U+0040). + return hba.quote(name) +} + // AllDatabases makes hba match connections made to any database. func (hba *HostBasedAuthentication) AllDatabases() *HostBasedAuthentication { hba.database = "all" @@ -71,9 +123,12 @@ func (hba *HostBasedAuthentication) AllUsers() *HostBasedAuthentication { return hba } -// Database makes hba match connections made to a specific database. -func (hba *HostBasedAuthentication) Database(name string) *HostBasedAuthentication { - hba.database = hba.quote(name) +// Databases makes hba match connections made to specific databases. +func (hba *HostBasedAuthentication) Databases(name string, names ...string) *HostBasedAuthentication { + hba.database = hba.quoteDatabase(name) + for _, n := range names { + hba.database += "," + hba.quoteDatabase(n) + } return hba } @@ -84,11 +139,17 @@ func (hba *HostBasedAuthentication) Local() *HostBasedAuthentication { } // Method specifies the authentication method to use when a connection matches hba. +// Method names are bare identifiers in pg_hba.conf and are not quoted. func (hba *HostBasedAuthentication) Method(name string) *HostBasedAuthentication { hba.method = name return hba } +func (hba *HostBasedAuthentication) Origin(name string) *HostBasedAuthentication { + hba.origin = hba.quote(name) + return hba +} + // Network makes hba match connection attempts from a block of IP addresses in CIDR notation. func (hba *HostBasedAuthentication) Network(block string) *HostBasedAuthentication { hba.address = hba.quote(block) @@ -104,8 +165,8 @@ func (hba *HostBasedAuthentication) NoSSL() *HostBasedAuthentication { // Options specifies any options for the authentication method. func (hba *HostBasedAuthentication) Options(opts map[string]string) *HostBasedAuthentication { hba.options = "" - for k, v := range opts { - hba.options = fmt.Sprintf("%s %s=%s", hba.options, k, hba.quote(v)) + for _, k := range slices.Sorted(maps.Keys(opts)) { + hba.options = fmt.Sprintf("%s %s=%s", hba.options, hba.quote(k), hba.quote(opts[k])) } return hba } @@ -116,12 +177,6 @@ func (hba *HostBasedAuthentication) Replication() *HostBasedAuthentication { return hba } -// Role makes hba match connections by users that are members of a specific role. -func (hba *HostBasedAuthentication) Role(name string) *HostBasedAuthentication { - hba.user = "+" + hba.quote(name) - return hba -} - // SameNetwork makes hba match connection attempts from IP addresses in any // subnet to which the server is directly connected. func (hba *HostBasedAuthentication) SameNetwork() *HostBasedAuthentication { @@ -129,28 +184,35 @@ func (hba *HostBasedAuthentication) SameNetwork() *HostBasedAuthentication { return hba } -// TLS makes hba match connection attempts made using TCP/IP with TLS. -func (hba *HostBasedAuthentication) TLS() *HostBasedAuthentication { - hba.origin = "hostssl" - return hba -} - +// TLSOnly changes the HBA record to require TLS. Records that already require +// TLS ("hostssl") or use Unix sockets ("local") are not modified. TCP records +// ("host") and no-SSL records ("hostnossl") become "hostssl". func (hba *HostBasedAuthentication) TLSOnly() *HostBasedAuthentication { - if hba.origin == "host" || hba.origin == "hostnossl" { + switch hba.origin { + case "host", "hostnossl": hba.origin = "hostssl" } return hba } +// TLS makes hba match connection attempts made using TCP/IP with TLS. +func (hba *HostBasedAuthentication) TLS() *HostBasedAuthentication { + hba.origin = "hostssl" + return hba +} + // TCP makes hba match connection attempts made using TCP/IP, with or without SSL. func (hba *HostBasedAuthentication) TCP() *HostBasedAuthentication { hba.origin = "host" return hba } -// User makes hba match connections by a specific user. -func (hba *HostBasedAuthentication) User(name string) *HostBasedAuthentication { - hba.user = hba.quote(name) +// Users makes hba match connections by specific users. +func (hba *HostBasedAuthentication) Users(name string, names ...string) *HostBasedAuthentication { + hba.user = hba.quoteUser(name) + for _, n := range names { + hba.user += "," + hba.quoteUser(n) + } return hba } @@ -164,3 +226,43 @@ func (hba *HostBasedAuthentication) String() string { return strings.TrimSpace(fmt.Sprintf("%s %s %s %s %s %s", hba.origin, hba.database, hba.user, hba.address, hba.method, hba.options)) } + +// OrderedHBAs is an append-only sequence of pg_hba.conf lines. +type OrderedHBAs struct { + records []string +} + +// Append renders and adds pg_hba.conf lines to o. Nil pointers are ignored. +func (o *OrderedHBAs) Append(hbas ...*HostBasedAuthentication) { + for _, hba := range hbas { + if hba != nil { + o.records = append(o.records, hba.String()) + } + } +} + +// AppendUnstructured trims and adds unvalidated pg_hba.conf lines to o. +// Empty lines and lines that are entirely control characters are omitted. +func (o *OrderedHBAs) AppendUnstructured(hbas ...string) { + for _, hba := range hbas { + hba = strings.TrimFunc(hba, func(r rune) bool { + // control characters, space, and backslash + return r > '~' || r < '!' || r == '\\' + }) + + // NOTE: Skipping "include" directives here is a security measure. + if len(hba) > 0 && !strings.HasPrefix(hba, "include") { + o.records = append(o.records, hba) + } + } +} + +// AsStrings returns a copy of o as a slice. +func (o *OrderedHBAs) AsStrings() []string { + return slices.Clone(o.records) +} + +// Length returns the number of records in o. +func (o *OrderedHBAs) Length() int { + return len(o.records) +} diff --git a/internal/postgres/hba_test.go b/internal/postgres/hba_test.go index 1ae97f9c04..5e2900c853 100644 --- a/internal/postgres/hba_test.go +++ b/internal/postgres/hba_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2026 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -36,27 +36,127 @@ hostssl "postgres" "_crunchyrepl" all cert host all "_crunchyrepl" all reject `)) assert.Assert(t, matches(hba.Default, ` -hostssl all all all md5 +hostssl all all all scram-sha-256 `)) } func TestHostBasedAuthentication(t *testing.T) { - assert.Equal(t, `local all "postgres" peer`, - NewHBA().Local().User("postgres").Method("peer").String()) + assert.Equal(t, `local all "postgres","pgo" peer`, + NewHBA().Local().Users("postgres", "pgo").Method("peer").String()) assert.Equal(t, `host all all "::1/128" trust`, NewHBA().TCP().Network("::1/128").Method("trust").String()) assert.Equal(t, `host replication "KD6-3.7" samenet scram-sha-256`, NewHBA().TCP().SameNetwork().Replication(). - User("KD6-3.7").Method("scram-sha-256"). + Users("KD6-3.7").Method("scram-sha-256"). String()) - assert.Equal(t, `hostssl "data" +"admin" all md5 clientcert="verify-ca"`, - NewHBA().TLS().Database("data").Role("admin"). + assert.Equal(t, `hostssl "data","bits" all all md5 "clientcert"="verify-ca"`, + NewHBA().TLS().Databases("data", "bits"). Method("md5").Options(map[string]string{"clientcert": "verify-ca"}). String()) assert.Equal(t, `hostnossl all all all reject`, NewHBA().NoSSL().Method("reject").String()) + + t.Run("OptionsSorted", func(t *testing.T) { + assert.Equal(t, `hostssl all all all ldap "ldapbasedn"="dc=example,dc=org" "ldapserver"="example.org"`, + NewHBA().TLS().Method("ldap").Options(map[string]string{ + "ldapserver": "example.org", + "ldapbasedn": "dc=example,dc=org", + }).String()) + }) + + t.Run("SpecialCharactersEscaped", func(t *testing.T) { + // Databases; slash U+002F triggers regex escaping; regex characters themselves do not + assert.Equal(t, `local "/^[/]asdf_[+][?]1234$","/^[/][*][$]$","+*$" all`, + NewHBA().Local().Databases(`/asdf_+?1234`, `/*$`, `+*$`).String()) + + // Users; slash U+002F triggers regex escaping; regex characters themselves do not + assert.Equal(t, `local all "/^[/]asdf_[+][?]1234$","/^[/][*][$]$","+*$"`, + NewHBA().Local().Users(`/asdf_+?1234`, `/*$`, `+*$`).String()) + }) +} + +func TestOrderedHBAs(t *testing.T) { + ordered := new(OrderedHBAs) + + // The zero value is empty. + assert.Equal(t, ordered.Length(), 0) + assert.Assert(t, cmp.Len(ordered.AsStrings(), 0)) + + // Append can be called without arguments. + ordered.Append() + ordered.AppendUnstructured() + assert.Assert(t, cmp.Len(ordered.AsStrings(), 0)) + + // Append adds to the end of the slice. + ordered.Append(NewHBA()) + assert.Equal(t, ordered.Length(), 1) + assert.DeepEqual(t, ordered.AsStrings(), []string{ + `all all all`, + }) + + // AppendUnstructured adds to the end of the slice. + ordered.AppendUnstructured("could be anything, really") + assert.Equal(t, ordered.Length(), 2) + assert.DeepEqual(t, ordered.AsStrings(), []string{ + `all all all`, + `could be anything, really`, + }) + + // Append and AppendUnstructured do not have a separate order. + ordered.Append(NewHBA().Users("zoro")) + assert.Equal(t, ordered.Length(), 3) + assert.DeepEqual(t, ordered.AsStrings(), []string{ + `all all all`, + `could be anything, really`, + `all "zoro" all`, + }) + + t.Run("NilPointersIgnored", func(t *testing.T) { + rules := new(OrderedHBAs) + rules.Append( + NewHBA(), nil, + NewHBA(), nil, + ) + assert.DeepEqual(t, rules.AsStrings(), []string{ + `all all all`, + `all all all`, + }) + }) + + // See [internal/testing/validation.TestPostgresAuthenticationRules] + t.Run("NoInclude", func(t *testing.T) { + rules := new(OrderedHBAs) + rules.AppendUnstructured( + `one`, + `include "/etc/passwd"`, + ` include_dir /tmp`, + `include_if_exists postgresql.auto.conf`, + `two`, + ) + assert.DeepEqual(t, rules.AsStrings(), []string{ + `one`, + `two`, + }) + }) + + t.Run("SpecialCharactersStripped", func(t *testing.T) { + rules := new(OrderedHBAs) + rules.AppendUnstructured( + " \n\t things \n\n\n", + `with # comment`, + " \n\t \\\\ \f", // entirely special characters + `trailing slashes \\\`, + "multiple \\\n lines okay", + ) + assert.DeepEqual(t, rules.AsStrings(), []string{ + `things`, + `with # comment`, + `trailing slashes`, + "multiple \\\n lines okay", + }) + }) } diff --git a/internal/postgres/reconcile.go b/internal/postgres/reconcile.go index ab17fb59d3..87c7942306 100644 --- a/internal/postgres/reconcile.go +++ b/internal/postgres/reconcile.go @@ -278,7 +278,7 @@ func InstancePod(ctx context.Context, startup.VolumeMounts = append(startup.VolumeMounts, tablespaceVolumeMount) } - if len(inCluster.Spec.Config.Files) != 0 { + if inCluster.Spec.Config != nil && len(inCluster.Spec.Config.Files) != 0 { additionalConfigVolumeMount := AdditionalConfigVolumeMount() additionalConfigVolume := corev1.Volume{Name: additionalConfigVolumeMount.Name} additionalConfigVolume.Projected = &corev1.ProjectedVolumeSource{ diff --git a/internal/postgres/reconcile_test.go b/internal/postgres/reconcile_test.go index 1a3f0dba95..3f7fa6c338 100644 --- a/internal/postgres/reconcile_test.go +++ b/internal/postgres/reconcile_test.go @@ -476,11 +476,13 @@ volumes: t.Run("WithAdditionalConfigFiles", func(t *testing.T) { clusterWithConfig := cluster.DeepCopy() - clusterWithConfig.Spec.Config.Files = []corev1.VolumeProjection{ - { - Secret: &corev1.SecretProjection{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "keytab", + clusterWithConfig.Spec.Config = &v1beta1.PostgresConfigSpec{ + Files: []corev1.VolumeProjection{ + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "keytab", + }, }, }, }, @@ -1141,11 +1143,13 @@ volumes: t.Run("WithAdditionalConfigFiles", func(t *testing.T) { clusterWithConfig := cluster.DeepCopy() - clusterWithConfig.Spec.Config.Files = []corev1.VolumeProjection{ - { - Secret: &corev1.SecretProjection{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "keytab", + clusterWithConfig.Spec.Config = &v1beta1.PostgresConfigSpec{ + Files: []corev1.VolumeProjection{ + { + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "keytab", + }, }, }, }, diff --git a/internal/postgres/validation.go b/internal/postgres/validation.go new file mode 100644 index 0000000000..718f6f6f4b --- /dev/null +++ b/internal/postgres/validation.go @@ -0,0 +1,114 @@ +// Copyright 2021 - 2026 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import ( + "path" + "regexp" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/tools/record" + + "github.com/percona/percona-postgresql-operator/v2/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// truncateAt returns s truncated to n bytes, or s unchanged when len(s) <= n. +func truncateAt(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +// SanitizeParameters transforms parameters so they are safe for Postgres in cluster. +func SanitizeParameters(cluster *v1beta1.PostgresCluster, parameters *ParameterSet, recorder record.EventRecorder) { + if v, ok := parameters.Get("log_directory"); ok { + parameters.Add("log_directory", sanitizeLogDirectory(cluster, v, recorder)) + } +} + +// sensitiveAbsolutePath matches absolute paths that Postgres expects to control. +// User input should not direct tools to write to these directories. +// +// See [sanitizeLogDirectory]. +var sensitiveAbsolutePath = regexp.MustCompile( + // Rooted in one of these volumes + `^(` + dataMountPath + `|` + tmpMountPath + `|` + walMountPath + `)` + + + // First subdirectory is a Postgres directory + `/(` + `pg\d+` + // [DataDirectory] + `|` + `pgsql_tmp` + // https://www.postgresql.org/docs/current/storage-file-layout.html + `|` + `pg\d+_wal` + // [WALDirectory] + `)(/|$)`, +) + +// sensitiveRelativePath matches paths relative to the Postgres "data_directory" that Postgres expects to control. +// Arguably, everything inside the data directory is sensitve, but this is here because +// Postges interprets some of its parameters relative to its data directory. +// +// User input should not direct tools to write to these directories. +// +// NOTE: This is not an exhaustive list! New code should use an allowlist rather than this denylist. +// +// See [sanitizeLogDirectory]. +var sensitiveRelativePath = regexp.MustCompile( + `^(archive|base|current|global|patroni|pg_|PG_|postgresql|postmaster|[[:xdigit:]]{24,})` + + `|` + `[.](history|partial)$`, +) + +// sanitizeLogDirectory returns the absolute path to input when it is a safe "log_directory" for cluster. +// Otherwise, it returns the absolute path to a good "log_directory" value. +// +// https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-DIRECTORY +func sanitizeLogDirectory(cluster *v1beta1.PostgresCluster, input string, recorder record.EventRecorder) string { + directory := path.Clean(input) + + // [path.Clean] leaves leading parent directories. Eliminate these as a security measure. + for strings.HasPrefix(directory, "../") { + directory = directory[3:] + } + + switch { + case directory == "log": + // This the Postgres default and the only relative path allowed in v1 of PostgresCluster. + // Expand it relative to the data directory like Postgres does. + return path.Join(DataDirectory(cluster), "log") + + case directory == "", directory == ".", directory == "/", + sensitiveAbsolutePath.MatchString(directory), + sensitiveRelativePath.MatchString(directory): + if recorder != nil { + recorder.Eventf(cluster, corev1.EventTypeWarning, "InvalidParameter", + "Ignoring unsafe Postgres parameter value %q = %q", "log_directory", truncateAt(input, 128)) + } + + // When the value is empty after cleaning or disallowed, choose one instead. + // Keep it on the same volume, if possible. + if strings.HasPrefix(directory, tmpMountPath) { + return path.Join(tmpMountPath, "logs/postgres") + } + if strings.HasPrefix(directory, walMountPath) { + return path.Join(walMountPath, "logs/postgres") + } + + // There is always a data volume, so use that. + return path.Join(dataMountPath, "logs/postgres") + + case !path.IsAbs(directory): + if recorder != nil { + recorder.Eventf(cluster, corev1.EventTypeWarning, "InvalidParameter", + "Postgres parameter %q should be %q or an absolute path", "log_directory", "log") + } + + // Directory is relative. This is disallowed since v1 of PostgresCluster. + // Expand it relative to the data directory like Postgres does. + return path.Join(DataDirectory(cluster), directory) + + default: + // Directory is absolute and considered safe; use it. + return directory + } +} diff --git a/internal/postgres/validation_test.go b/internal/postgres/validation_test.go new file mode 100644 index 0000000000..27696be201 --- /dev/null +++ b/internal/postgres/validation_test.go @@ -0,0 +1,81 @@ +// Copyright 2021 - 2026 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import ( + "path" + "testing" + + "gotest.tools/v3/assert" + + "github.com/percona/percona-postgresql-operator/v2/internal/controller/runtime" + "github.com/percona/percona-postgresql-operator/v2/internal/testing/cmp" + "github.com/percona/percona-postgresql-operator/v2/internal/testing/events" + "github.com/percona/percona-postgresql-operator/v2/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestSanitizeLogDirectory(t *testing.T) { + t.Parallel() + + cluster := new(v1beta1.PostgresCluster) + cluster.Spec.PostgresVersion = 18 + cluster.UID = "doot" + + recorder := events.NewRecorder(t, runtime.Scheme) + + for _, tt := range []struct{ expected, from, event string }{ + // User wants logs inside the data directory. + {expected: "/pgdata/pg18/log", from: "log"}, + + // Other relative paths warn. + { + expected: "/pgdata/pg18/some/directory", from: "some/directory", + event: `"log_directory" should be "log" or an absolute path`, + }, + + // Postgres interprets blank to mean root. + // That's no good, so we choose better. + {expected: "/pgdata/logs/postgres", from: "", event: `"log_directory" = ""`}, + {expected: "/pgdata/logs/postgres", from: "/", event: `"log_directory" = "/"`}, + + // Paths into Postgres directories are replaced on the same volume. + { + expected: "/pgdata/logs/postgres", from: ".", event: `"log_directory" = "."`, + }, { + expected: "/pgdata/logs/postgres", from: "global", event: `"log_directory" = "global"`, + }, { + expected: "/pgdata/logs/postgres", from: "postgresql.conf", event: `"log_directory" = "postgresql.conf"`, + }, { + expected: "/pgdata/logs/postgres", from: "postgresql.auto.conf", event: `"log_directory" = "postgresql.auto.conf"`, + }, { + expected: "/pgdata/logs/postgres", from: "pg_wal", event: `"log_directory" = "pg_wal"`, + }, { + expected: "/pgdata/logs/postgres", from: "/pgdata/pg99/any", event: `"log_directory" = "/pgdata/pg99/any"`, + }, { + expected: "/pgdata/logs/postgres", from: "/pgdata/pg18_wal", event: `"log_directory" = "/pgdata/pg18_wal"`, + }, { + expected: "/pgdata/logs/postgres", from: "/pgdata/pgsql_tmp/ludicrous/speed", event: `"log_directory" = "/pgdata/pgsql_tmp/ludicrous/speed"`, + }, { + expected: "/pgwal/logs/postgres", from: "/pgwal/pg18_wal", event: `"log_directory" = "/pgwal/pg18_wal"`, + }, { + expected: "/pgtmp/logs/postgres", from: "/pgtmp/pg18_wal", event: `"log_directory" = "/pgtmp/pg18_wal"`, + }, + } { + recorder.Events = nil + result := sanitizeLogDirectory(cluster, tt.from, recorder) + + assert.Equal(t, tt.expected, result, "from: %s", tt.from) + assert.Assert(t, path.IsAbs(result)) + + if len(tt.event) > 0 { + assert.Assert(t, cmp.Len(recorder.Events, 1)) + assert.Equal(t, recorder.Events[0].Type, "Warning") + assert.Equal(t, recorder.Events[0].Reason, "InvalidParameter") + assert.Equal(t, recorder.Events[0].Regarding.Kind, "PostgresCluster") + assert.Assert(t, cmp.Equal(recorder.Events[0].Regarding.UID, "doot")) + assert.Assert(t, cmp.Contains(recorder.Events[0].Note, tt.event)) + } + } +} diff --git a/internal/testing/cmp/cmp.go b/internal/testing/cmp/cmp.go index 47884777e4..5ba1878e9d 100644 --- a/internal/testing/cmp/cmp.go +++ b/internal/testing/cmp/cmp.go @@ -1,10 +1,12 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2026 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package cmp import ( + stdcmp "cmp" + "regexp" "strings" gocmp "github.com/google/go-cmp/cmp" @@ -46,10 +48,30 @@ func Contains(collection, item any) Comparison { // succeeds if the values are equal. The comparison can be customized using // comparison Options. See [github.com/google/go-cmp/cmp.Option] constructors // and [github.com/google/go-cmp/cmp/cmpopts]. -func DeepEqual(x, y any, opts ...gocmp.Option) Comparison { +func DeepEqual[T any](x, y T, opts ...gocmp.Option) Comparison { return gotest.DeepEqual(x, y, opts...) } +// Equal succeeds if x == y, the same as [gotest.tools/v3/assert.Equal]. +// The type constraint makes it easier to compare against numeric literals and typed constants. +func Equal[T any](x, y T) Comparison { + return gotest.Equal(x, y) +} + +// Len succeeds if actual has the expected length. +func Len[Slice ~[]E, E any](actual Slice, expected int) Comparison { + return gotest.Len(actual, expected) +} + +// LenMap succeeds if actual has the expected length. +func LenMap[Map ~map[K]V, K comparable, V any](actual Map, expected int) Comparison { + // There doesn't seem to be a way to express "map or slice" in type constraints + // that [Go 1.22] compiler can nicely infer. Ideally, this function goes + // away when a better constraint can be expressed on [Len]. + + return gotest.Len(actual, expected) +} + // MarshalContains converts actual to YAML and succeeds if expected is in the result. func MarshalContains(actual any, expected string) Comparison { b, err := yaml.Marshal(actual) @@ -65,12 +87,78 @@ func MarshalMatches(actual any, expected string) Comparison { if err != nil { return func() gotest.Result { return gotest.ResultFromError(err) } } - return gotest.DeepEqual(string(b), strings.Trim(expected, "\t\n")+"\n") + return gotest.DeepEqual(string(b), dedentLines( + strings.TrimLeft(strings.TrimRight(expected, "\t\n"), "\n"), 2, + )) } -// Regexp succeeds if value contains any match of the regular expression re. +// Or is an alias to [stdcmp.Or] in the standard library: +// - It returns the leftmost argument that is not zero. +// - It returns zero when all its arguments are zero. +// +// This is here so test authors can import fewer "cmp" packages. +func Or[T comparable](values ...T) T { + return stdcmp.Or(values...) +} + +// Regexp succeeds if value contains any match of the regular expression. // The regular expression may be a *regexp.Regexp or a string that is a valid // regexp pattern. -func Regexp(re any, value string) Comparison { - return gotest.Regexp(re, value) +func Regexp[RE *regexp.Regexp | ~string](regex RE, value string) Comparison { + return gotest.Regexp(regex, value) +} + +var leadingTabs = regexp.MustCompile(`^\t+`) + +// dedentLines finds the shortest leading whitespace of every line in data and then removes it from every line. +// When tabWidth is positive, leading tabs are converted to spaces first. +func dedentLines(data string, tabWidth int) string { + if len(data) < 1 { + return "" + } + + var lines = make([]string, 0, 20) + var lowest, highest string + + for line := range strings.Lines(data) { + tabs := leadingTabs.FindString(line) + + // Replace any leading tabs with spaces when tabWidth is positive. + // NOTE: [strings.Repeat] has a fast-path for spaces. + if need := tabWidth * len(tabs); need > 0 { + line = strings.Repeat(" ", need) + line[len(tabs):] + } + + switch { + case lowest == "", highest == "": + lowest, highest = line, line + + case len(strings.TrimSpace(line)) > 0: + lowest = min(lowest, line) + highest = max(highest, line) + } + + lines = append(lines, line) + } + + // This treats one tab the same as one space. + // That is, it expects all lines to be indented using spaces or using tabs; not both. + if width := func() int { + for i := range lowest { + if (lowest[i] != ' ' && lowest[i] != '\t') || lowest[i] != highest[i] { + return i + } + } + return len(lowest) + }(); width > 0 { + for i := range lines { + if len(lines[i]) > width { + lines[i] = lines[i][width:] + } else { + lines[i] = "\n" + } + } + } + + return strings.TrimSuffix(strings.Join(lines, ""), "\n") + "\n" } diff --git a/internal/testing/cmp/cmp_test.go b/internal/testing/cmp/cmp_test.go new file mode 100644 index 0000000000..efabe96d63 --- /dev/null +++ b/internal/testing/cmp/cmp_test.go @@ -0,0 +1,66 @@ +// Copyright 2021 - 2026 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package cmp + +import ( + "fmt" + "testing" + + "gotest.tools/v3/assert" +) + +func TestDedentLines(t *testing.T) { + for _, tc := range []struct { + width int + input string + expected string + }{ + // empty stays that way + {width: 0, input: "", expected: ""}, + {width: 1, input: "", expected: ""}, + {width: 2, input: "", expected: ""}, + + // adds a missing newline + {input: "\n", expected: "\n"}, + {input: "x", expected: "x\n"}, + {input: "x\n", expected: "x\n"}, + {input: "x\n\n", expected: "x\n\n"}, + + // width does not affect whats removed + {width: 2, input: "x", expected: "x\n"}, + {width: 2, input: "\tx", expected: "x\n"}, + {width: 2, input: " x", expected: "x\n"}, + {width: 2, input: " x", expected: "x\n"}, + {width: 2, input: " x", expected: "x\n"}, + + // positive width changes tabs to spaces + {width: 0, input: "\t\t~\n\t~\n", expected: "\t~\n~\n"}, + {width: 1, input: "\t\t~\n\t~\n", expected: " ~\n~\n"}, + {width: 2, input: "\t\t~\n\t~\n", expected: " ~\n~\n"}, + + // width does not affect spaces + {width: 0, input: " ~\n ~\n", expected: " ~\n~\n"}, + {width: 1, input: " ~\n ~\n", expected: " ~\n~\n"}, + {width: 2, input: " ~\n ~\n", expected: " ~\n~\n"}, + + // smallest indent can be anywhere + {input: " ~\n ~\n ~\n", expected: "~\n ~\n ~\n"}, + {input: " ~\n ~\n ~\n", expected: " ~\n~\n ~\n"}, + {input: " ~\n ~\n ~\n", expected: " ~\n ~\n~\n"}, + + // entirely whitespace becomes newline + {input: " ", expected: "\n"}, + {input: " ", expected: "\n"}, + {input: "\t", expected: "\n"}, + {input: "\t\t", expected: "\n"}, + + // blank lines preserved + {input: " ~\n\n ~\n", expected: "~\n\n~\n"}, + } { + t.Run(fmt.Sprintf("%v:%#v", tc.width, tc.input), func(t *testing.T) { + assert.DeepEqual(t, dedentLines(tc.input, tc.width), tc.expected) + }) + } +} diff --git a/pkg/apis/pgv2.percona.com/v2/perconapgcluster_types.go b/pkg/apis/pgv2.percona.com/v2/perconapgcluster_types.go index 773ca1198d..2a835f420a 100644 --- a/pkg/apis/pgv2.percona.com/v2/perconapgcluster_types.go +++ b/pkg/apis/pgv2.percona.com/v2/perconapgcluster_types.go @@ -177,6 +177,19 @@ type PerconaPGClusterSpec struct { AutoCreateUserSchema *bool `json:"autoCreateUserSchema,omitempty"` ClusterServiceDNSSuffix string `json:"clusterServiceDNSSuffix,omitempty"` + + // Configuration for PostgreSQL config files and server parameters. + // Use spec.config.files to mount files (e.g. LDAP CA certificate) under + // /etc/postgres, and spec.config.parameters to set postgresql.conf values. + // +optional + Config *crunchyv1beta1.PostgresConfigSpec `json:"config,omitempty"` + + // Defines custom pg_hba.conf authentication rules. Rules are evaluated + // after mandatory operator rules and before the default scram-sha-256 + // fallback. Use this together with spec.config.files to supply supporting + // files such as an LDAP CA certificate. + // +optional + Authentication *crunchyv1beta1.PostgresClusterAuthentication `json:"authentication,omitempty"` } type ContainerOptions struct { @@ -416,6 +429,8 @@ func (cr *PerconaPGCluster) ToCrunchy(ctx context.Context, postgresCluster *crun postgresCluster.Spec.InitContainer = cr.Spec.InitContainer postgresCluster.Spec.ClusterServiceDNSSuffix = cr.Spec.ClusterServiceDNSSuffix + postgresCluster.Spec.Config = cr.Spec.Config + postgresCluster.Spec.Authentication = cr.Spec.Authentication return postgresCluster, nil } diff --git a/pkg/apis/pgv2.percona.com/v2/zz_generated.deepcopy.go b/pkg/apis/pgv2.percona.com/v2/zz_generated.deepcopy.go index 7371f8a20c..dd41aba7f5 100644 --- a/pkg/apis/pgv2.percona.com/v2/zz_generated.deepcopy.go +++ b/pkg/apis/pgv2.percona.com/v2/zz_generated.deepcopy.go @@ -928,6 +928,16 @@ func (in *PerconaPGClusterSpec) DeepCopyInto(out *PerconaPGClusterSpec) { *out = new(bool) **out = **in } + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(v1beta1.PostgresConfigSpec) + (*in).DeepCopyInto(*out) + } + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = new(v1beta1.PostgresClusterAuthentication) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PerconaPGClusterSpec. diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_test.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_test.go index 8a904258a8..4aa3197e66 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_test.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_test.go @@ -44,7 +44,6 @@ metadata: {} spec: backups: pgbackrest: {} - config: {} extensions: {} instances: null patroni: @@ -77,7 +76,6 @@ metadata: {} spec: backups: pgbackrest: {} - config: {} extensions: {} instances: - dataVolumeClaimSpec: diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types.go index e0bef063b2..e9d0726d9f 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types.go @@ -185,7 +185,14 @@ type PostgresClusterSpec struct { // +optional Users []PostgresUserSpec `json:"users,omitempty"` - Config PostgresAdditionalConfig `json:"config,omitempty"` + // +optional + Config *PostgresConfigSpec `json:"config,omitempty"` + + // Defines additional authentication rules for PostgreSQL host-based + // authentication (pg_hba.conf). Rules added here are applied after any + // mandatory rules and before the default scram-sha-256 fallback. + // +optional + Authentication *PostgresClusterAuthentication `json:"authentication,omitempty"` Extensions ExtensionsSpec `json:"extensions,omitempty"` @@ -706,8 +713,107 @@ type PostgresUserInterfaceStatus struct { PGAdmin PGAdminPodStatus `json:"pgAdmin,omitempty"` } -type PostgresAdditionalConfig struct { +type PostgresConfigSpec struct { + // Files to mount under "/etc/postgres". + // --- + // +optional Files []corev1.VolumeProjection `json:"files,omitempty"` + + // Configuration parameters for the PostgreSQL server. Some values will + // be reloaded without validation and some cause PostgreSQL to restart. + // Some values cannot be changed at all. + // More info: https://www.postgresql.org/docs/current/runtime-config.html + // --- + // + // Postgres 17 has something like 350+ built-in parameters, but typically + // an administrator will change only a handful of these. + // +kubebuilder:validation:MaxProperties=50 + // + // # File Locations + // - https://www.postgresql.org/docs/current/runtime-config-file-locations.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.config_file) && !has(self.data_directory)`,message=`cannot change PGDATA path: config_file, data_directory` + // +kubebuilder:validation:XValidation:rule=`!has(self.external_pid_file)`,message=`cannot change external_pid_file` + // +kubebuilder:validation:XValidation:rule=`!has(self.hba_file) && !has(self.ident_file)`,message=`cannot change authentication path: hba_file, ident_file` + // + // # Connections + // - https://www.postgresql.org/docs/current/runtime-config-connection.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.listen_addresses)`,message=`network connectivity is always enabled: listen_addresses` + // +kubebuilder:validation:XValidation:rule=`!has(self.port)`,message=`change port using .spec.port instead` + // +kubebuilder:validation:XValidation:rule=`!has(self.ssl) && !self.exists(k, k.startsWith("ssl_") && !(k == 'ssl_groups' || k == 'ssl_ecdh_curve'))`,message=`TLS is always enabled` + // +kubebuilder:validation:XValidation:rule=`!self.exists(k, k.startsWith("unix_socket_"))`,message=`domain socket paths cannot be changed` + // + // # Write Ahead Log + // - https://www.postgresql.org/docs/current/runtime-config-wal.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.wal_level) || self.wal_level in ["logical"]`,message=`wal_level must be "replica" or higher` + // +kubebuilder:validation:XValidation:rule=`!has(self.wal_log_hints)`,message=`wal_log_hints are always enabled` + // +kubebuilder:validation:XValidation:rule=`!has(self.archive_mode) && !has(self.archive_command) && !has(self.restore_command)` + // +kubebuilder:validation:XValidation:rule=`!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))` + // + // # Replication + // - https://www.postgresql.org/docs/current/runtime-config-replication.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.hot_standby)`,message=`hot_standby is always enabled` + // +kubebuilder:validation:XValidation:rule=`!has(self.synchronous_standby_names)` + // +kubebuilder:validation:XValidation:rule=`!has(self.primary_conninfo) && !has(self.primary_slot_name)` + // +kubebuilder:validation:XValidation:rule=`!has(self.recovery_min_apply_delay)`,message=`delayed replication is not supported at this time` + // + // # Logging + // - https://www.postgresql.org/docs/current/runtime-config-logging.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.cluster_name)`,message=`cluster_name is derived from the PostgresCluster name` + // +kubebuilder:validation:XValidation:rule=`!has(self.logging_collector)`,message=`disabling logging_collector is unsafe` + // +kubebuilder:validation:XValidation:rule=`!has(self.log_file_mode)`,message=`log_file_mode cannot be changed` + // + // +mapType=granular + // +optional + Parameters map[string]intstr.IntOrString `json:"parameters,omitempty"` +} + +// PostgresHBARule defines a structured pg_hba.conf record. +// - https://www.postgresql.org/docs/current/auth-pg-hba-conf.html +type PostgresHBARule struct { + // Connection type: local, host, hostssl, hostnossl, hostgssenc, hostnogssenc. + // +kubebuilder:validation:Required + Connection string `json:"connection"` + + // Authentication method to use when a connection matches this rule. + // +kubebuilder:validation:Required + Method string `json:"method"` + + // Databases to match. An empty list matches all databases. + // +optional + Databases []string `json:"databases,omitempty"` + + // Users to match. An empty list matches all users. + // +optional + Users []string `json:"users,omitempty"` + + // Options for the authentication method (e.g. ldapserver, ldapport). + // +optional + Options map[string]intstr.IntOrString `json:"options,omitempty"` +} + +// PostgresAuthenticationRule defines a single pg_hba.conf entry. Use either +// the structured fields or the raw HBA line, not both. +type PostgresAuthenticationRule struct { + PostgresHBARule `json:",inline"` + + // A raw pg_hba.conf line. When non-empty, this line is used as-is and the + // structured fields are ignored. + // +optional + HBA string `json:"hba,omitempty"` +} + +// PostgresClusterAuthentication defines custom pg_hba.conf rules for a cluster. +type PostgresClusterAuthentication struct { + // Rules to include in pg_hba.conf. They are evaluated after mandatory + // operator rules and before the default scram-sha-256 fallback. + // +listType=atomic + // +optional + Rules []PostgresAuthenticationRule `json:"rules,omitempty"` } // +kubebuilder:object:root=true diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go index 38ec36be82..6071164c30 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go @@ -1688,23 +1688,17 @@ func (in *PatroniSwitchover) DeepCopy() *PatroniSwitchover { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PostgresAdditionalConfig) DeepCopyInto(out *PostgresAdditionalConfig) { +func (in *PostgresAuthenticationRule) DeepCopyInto(out *PostgresAuthenticationRule) { *out = *in - if in.Files != nil { - in, out := &in.Files, &out.Files - *out = make([]corev1.VolumeProjection, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } + in.PostgresHBARule.DeepCopyInto(&out.PostgresHBARule) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresAdditionalConfig. -func (in *PostgresAdditionalConfig) DeepCopy() *PostgresAdditionalConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresAuthenticationRule. +func (in *PostgresAuthenticationRule) DeepCopy() *PostgresAuthenticationRule { if in == nil { return nil } - out := new(PostgresAdditionalConfig) + out := new(PostgresAuthenticationRule) in.DeepCopyInto(out) return out } @@ -1736,6 +1730,28 @@ func (in *PostgresCluster) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresClusterAuthentication) DeepCopyInto(out *PostgresClusterAuthentication) { + *out = *in + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]PostgresAuthenticationRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresClusterAuthentication. +func (in *PostgresClusterAuthentication) DeepCopy() *PostgresClusterAuthentication { + if in == nil { + return nil + } + out := new(PostgresClusterAuthentication) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresClusterDataSource) DeepCopyInto(out *PostgresClusterDataSource) { *out = *in @@ -1938,7 +1954,16 @@ func (in *PostgresClusterSpec) DeepCopyInto(out *PostgresClusterSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - in.Config.DeepCopyInto(&out.Config) + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(PostgresConfigSpec) + (*in).DeepCopyInto(*out) + } + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = new(PostgresClusterAuthentication) + (*in).DeepCopyInto(*out) + } out.Extensions = in.Extensions if in.InitContainer != nil { in, out := &in.InitContainer, &out.InitContainer @@ -2009,6 +2034,67 @@ func (in *PostgresClusterStatus) DeepCopy() *PostgresClusterStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresConfigSpec) DeepCopyInto(out *PostgresConfigSpec) { + *out = *in + if in.Files != nil { + in, out := &in.Files, &out.Files + *out = make([]corev1.VolumeProjection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Parameters != nil { + in, out := &in.Parameters, &out.Parameters + *out = make(map[string]intstr.IntOrString, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresConfigSpec. +func (in *PostgresConfigSpec) DeepCopy() *PostgresConfigSpec { + if in == nil { + return nil + } + out := new(PostgresConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresHBARule) DeepCopyInto(out *PostgresHBARule) { + *out = *in + if in.Databases != nil { + in, out := &in.Databases, &out.Databases + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Users != nil { + in, out := &in.Users, &out.Users + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Options != nil { + in, out := &in.Options, &out.Options + *out = make(map[string]intstr.IntOrString, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresHBARule. +func (in *PostgresHBARule) DeepCopy() *PostgresHBARule { + if in == nil { + return nil + } + out := new(PostgresHBARule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresInstanceSetSpec) DeepCopyInto(out *PostgresInstanceSetSpec) { *out = *in diff --git a/testing/kuttl/e2e/ldap/00--openldap.yaml b/testing/kuttl/e2e/ldap/00--openldap.yaml new file mode 100644 index 0000000000..66f7835344 --- /dev/null +++ b/testing/kuttl/e2e/ldap/00--openldap.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: + - files/openldap.yaml +assert: + - files/openldap-ready.yaml diff --git a/testing/kuttl/e2e/ldap/00-assert.yaml b/testing/kuttl/e2e/ldap/00-assert.yaml new file mode 100644 index 0000000000..004b3857af --- /dev/null +++ b/testing/kuttl/e2e/ldap/00-assert.yaml @@ -0,0 +1,11 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +# OpenLDAP readiness probe has initialDelaySeconds: 60, so allow extra time. +timeout: 180 +collectors: + - type: command + command: kubectl -n $NAMESPACE describe deployment openldap + - type: command + command: kubectl -n $NAMESPACE logs deployment/openldap -c openldap --tail=50 + - type: command + command: kubectl -n $NAMESPACE logs deployment/openldap -c ldap-setup --tail=50 diff --git a/testing/kuttl/e2e/ldap/01--cluster.yaml b/testing/kuttl/e2e/ldap/01--cluster.yaml new file mode 100644 index 0000000000..9c1261463e --- /dev/null +++ b/testing/kuttl/e2e/ldap/01--cluster.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: + - files/cluster.yaml +assert: + - files/cluster-ready.yaml diff --git a/testing/kuttl/e2e/ldap/01-assert.yaml b/testing/kuttl/e2e/ldap/01-assert.yaml new file mode 100644 index 0000000000..cd52126ab6 --- /dev/null +++ b/testing/kuttl/e2e/ldap/01-assert.yaml @@ -0,0 +1,7 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +collectors: + - type: command + command: kubectl -n $NAMESPACE describe pods --selector postgres-operator.crunchydata.com/cluster=ldap + - namespace: $NAMESPACE + selector: postgres-operator.crunchydata.com/cluster=ldap diff --git a/testing/kuttl/e2e/ldap/02--test-ldap-auth.yaml b/testing/kuttl/e2e/ldap/02--test-ldap-auth.yaml new file mode 100644 index 0000000000..658e0b8698 --- /dev/null +++ b/testing/kuttl/e2e/ldap/02--test-ldap-auth.yaml @@ -0,0 +1,66 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + set -e + + PRIMARY=$( + kubectl get pod --namespace "${NAMESPACE}" \ + --output name --selector \ + 'postgres-operator.crunchydata.com/cluster=ldap,postgres-operator.crunchydata.com/role=master' \ + | head -1 + ) + + echo "Primary pod: ${PRIMARY}" + + # Verify pg_hba.conf contains the LDAP rule. + HBA=$(kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -c database \ + -- psql -tAc "SELECT pg_read_file('pg_hba.conf');" 2>&1) + echo "--- pg_hba.conf ---" + echo "${HBA}" + echo "-------------------" + + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + contains "${HBA}" "ldap" || { + echo >&2 'pg_hba.conf does not contain an ldap rule' + exit 1 + } + + # Verify the LDAP entries were added by the setup container. + # Retry to account for the 60-second sleep before ldapadd runs. + echo "Verifying LDAP directory entries..." + for i in $(seq 12); do + if kubectl exec --namespace "${NAMESPACE}" deployment/openldap -c ldap-setup \ + -- ldapsearch -x -H ldap://localhost:389 \ + -D "cn=admin,dc=ldap,dc=local" -w adminpassword \ + -b "uid=percona,ou=perconadba,dc=ldap,dc=local" 2>&1 \ + | grep -q "uid: percona"; then + echo "LDAP entry for percona found" + break + fi + echo "Attempt ${i}: LDAP entry not yet available, waiting..." + sleep 10 + done + + # Test LDAP authentication: connect as the percona user via TCP. + # The pg_hba.conf rule uses simple bind: + # uid={username},ou=perconadba,dc=ldap,dc=local → password: "password" + # PGSSLMODE=disable forces a plain TCP connection (matches the "host" rule). + echo "Testing LDAP authentication..." + result="" + for i in $(seq 6); do + result=$(kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -c database \ + -- bash -c 'PGPASSWORD=password PGSSLMODE=disable \ + psql -h 127.0.0.1 -U percona -d percona -tAc "SELECT current_user;" 2>&1') && break + echo "Attempt ${i} failed: ${result}" + sleep 10 + done + + echo "Result: ${result}" + [[ "${result}" == "percona" ]] || { + echo >&2 "Expected current_user='percona', got: ${result}" + exit 1 + } + + echo "LDAP authentication test passed" diff --git a/testing/kuttl/e2e/ldap/files/cluster-ready.yaml b/testing/kuttl/e2e/ldap/files/cluster-ready.yaml new file mode 100644 index 0000000000..9052332e60 --- /dev/null +++ b/testing/kuttl/e2e/ldap/files/cluster-ready.yaml @@ -0,0 +1,15 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: ldap +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: v1 +kind: Service +metadata: + name: ldap-primary diff --git a/testing/kuttl/e2e/ldap/files/cluster.yaml b/testing/kuttl/e2e/ldap/files/cluster.yaml new file mode 100644 index 0000000000..cc25e85452 --- /dev/null +++ b/testing/kuttl/e2e/ldap/files/cluster.yaml @@ -0,0 +1,33 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: ldap +spec: + postgresVersion: ${KUTTL_PG_VERSION} + instances: + - name: instance1 + replicas: 1 + dataVolumeClaimSpec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + users: + - name: percona + databases: + - percona + # Mount nothing under /etc/postgres for now; LDAPTLS_CACERT is still set + # by the operator and would be used if a CA cert were provided via + # spec.config.files. Plain LDAP (port 389) does not require a CA cert. + authentication: + rules: + - connection: host + method: ldap + users: + - percona + options: + ldapserver: openldap + ldapport: 389 + ldapprefix: "uid=" + ldapsuffix: ",ou=perconadba,dc=ldap,dc=local" diff --git a/testing/kuttl/e2e/ldap/files/openldap-ready.yaml b/testing/kuttl/e2e/ldap/files/openldap-ready.yaml new file mode 100644 index 0000000000..7941225b27 --- /dev/null +++ b/testing/kuttl/e2e/ldap/files/openldap-ready.yaml @@ -0,0 +1,7 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openldap +status: + availableReplicas: 1 + readyReplicas: 1 diff --git a/testing/kuttl/e2e/ldap/files/openldap.yaml b/testing/kuttl/e2e/ldap/files/openldap.yaml new file mode 100644 index 0000000000..51ea5d0246 --- /dev/null +++ b/testing/kuttl/e2e/ldap/files/openldap.yaml @@ -0,0 +1,126 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: openldap +type: Opaque +stringData: + adminpassword: adminpassword +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openldap + labels: + app.kubernetes.io/name: openldap +spec: + selector: + matchLabels: + app.kubernetes.io/name: openldap + replicas: 1 + template: + metadata: + labels: + app.kubernetes.io/name: openldap + spec: + containers: + - name: openldap + image: osixia/openldap:latest + imagePullPolicy: Always + args: ["--copy-service"] + env: + - name: LDAP_DOMAIN + value: "ldap.local" + - name: LDAP_ORGANISATION + value: "ldap.local" + - name: LDAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + key: adminpassword + name: openldap + - name: LDAP_CONFIG_PASSWORD + valueFrom: + secretKeyRef: + key: adminpassword + name: openldap + - name: LDAP_READONLY_USER + value: "true" + - name: LDAP_READONLY_USER_USERNAME + value: "readonly" + - name: LDAP_READONLY_USER_PASSWORD + value: "readonlypass" + - name: CONTAINER_LOG_LEVEL + value: "info" + ports: + - name: tcp-ldap + containerPort: 389 + readinessProbe: + tcpSocket: + port: 389 + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 5 + livenessProbe: + tcpSocket: + port: 389 + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + - name: ldap-setup + image: osixia/openldap:latest + imagePullPolicy: Always + command: ["/bin/bash"] + args: + - -c + - | + echo "Waiting for LDAP server to be ready..." + sleep 60 + + echo "Adding LDAP structure..." + ldapadd -x -H ldap://localhost:389 -D "cn=admin,dc=ldap,dc=local" -w adminpassword <