Merge branch 'master' into fes-support

This commit is contained in:
Felix Kunde 2022-02-21 11:29:39 +01:00
commit 47aa7b1c64
32 changed files with 1183 additions and 322 deletions

View File

@ -1,2 +1,2 @@
# global owners # global owners
* @erthalion @sdudoladov @Jan-M @CyberDem0n @avaczi @FxKu @RafiaSabih * @sdudoladov @Jan-M @CyberDem0n @FxKu @jopadi

View File

@ -1,5 +1,4 @@
Dmitrii Dolgov <dmitrii.dolgov@zalando.de>
Sergey Dudoladov <sergey.dudoladov@zalando.de> Sergey Dudoladov <sergey.dudoladov@zalando.de>
Felix Kunde <felix.kunde@zalando.de> Felix Kunde <felix.kunde@zalando.de>
Jan Mussler <jan.mussler@zalando.de> Jan Mussler <jan.mussler@zalando.de>
Rafia Sabih <rafia.sabih@zalando.de> Jociele Padilha <jociele.padilha@zalando.de>

View File

@ -61,6 +61,11 @@ spec:
configuration: configuration:
type: object type: object
properties: properties:
crd_categories:
type: array
nullable: true
items:
type: string
docker_image: docker_image:
type: string type: string
default: "registry.opensource.zalan.do/acid/spilo-14:2.1-p3" default: "registry.opensource.zalan.do/acid/spilo-14:2.1-p3"
@ -69,6 +74,7 @@ spec:
default: true default: true
enable_crd_validation: enable_crd_validation:
type: boolean type: boolean
description: deprecated
default: true default: true
enable_lazy_spilo_upgrade: enable_lazy_spilo_upgrade:
type: boolean type: boolean
@ -90,11 +96,13 @@ spec:
default: false default: false
max_instances: max_instances:
type: integer type: integer
minimum: -1 # -1 = disabled description: "-1 = disabled"
minimum: -1
default: -1 default: -1
min_instances: min_instances:
type: integer type: integer
minimum: -1 # -1 = disabled description: "-1 = disabled"
minimum: -1
default: -1 default: -1
resync_period: resync_period:
type: string type: string
@ -122,6 +130,15 @@ spec:
users: users:
type: object type: object
properties: properties:
enable_password_rotation:
type: boolean
default: false
password_rotation_interval:
type: integer
default: 90
password_rotation_user_retention:
type: integer
default: 180
replication_username: replication_username:
type: string type: string
default: standby default: standby
@ -175,12 +192,12 @@ spec:
type: array type: array
items: items:
type: string type: string
enable_init_containers:
type: boolean
default: true
enable_cross_namespace_secret: enable_cross_namespace_secret:
type: boolean type: boolean
default: false default: false
enable_init_containers:
type: boolean
default: true
enable_pod_antiaffinity: enable_pod_antiaffinity:
type: boolean type: boolean
default: false default: false
@ -233,6 +250,11 @@ spec:
type: object type: object
additionalProperties: additionalProperties:
type: string type: string
node_readiness_label_merge:
type: string
enum:
- "AND"
- "OR"
oauth_token_secret_name: oauth_token_secret_name:
type: string type: string
default: "postgresql-operator" default: "postgresql-operator"
@ -396,12 +418,12 @@ spec:
type: string type: string
log_s3_bucket: log_s3_bucket:
type: string type: string
wal_az_storage_account:
type: string
wal_gs_bucket: wal_gs_bucket:
type: string type: string
wal_s3_bucket: wal_s3_bucket:
type: string type: string
wal_az_storage_account:
type: string
logical_backup: logical_backup:
type: object type: object
properties: properties:

View File

@ -147,7 +147,7 @@ spec:
- "transaction" - "transaction"
numberOfInstances: numberOfInstances:
type: integer type: integer
minimum: 2 minimum: 1
resources: resources:
type: object type: object
required: required:
@ -201,8 +201,9 @@ spec:
type: boolean type: boolean
enableShmVolume: enableShmVolume:
type: boolean type: boolean
init_containers: # deprecated init_containers:
type: array type: array
description: deprecated
nullable: true nullable: true
items: items:
type: object type: object
@ -229,8 +230,8 @@ spec:
items: items:
type: object type: object
required: required:
- weight
- preference - preference
- weight
properties: properties:
preference: preference:
type: object type: object
@ -348,8 +349,9 @@ spec:
type: object type: object
additionalProperties: additionalProperties:
type: string type: string
pod_priority_class_name: # deprecated pod_priority_class_name:
type: string type: string
description: deprecated
podPriorityClassName: podPriorityClassName:
type: string type: string
postgresql: postgresql:
@ -393,8 +395,9 @@ spec:
type: boolean type: boolean
secretNamespace: secretNamespace:
type: string type: string
replicaLoadBalancer: # deprecated replicaLoadBalancer:
type: boolean type: boolean
description: deprecated
resources: resources:
type: object type: object
required: required:
@ -545,14 +548,14 @@ spec:
- PreferNoSchedule - PreferNoSchedule
tolerationSeconds: tolerationSeconds:
type: integer type: integer
useLoadBalancer: # deprecated useLoadBalancer:
type: boolean type: boolean
description: deprecated
users: users:
type: object type: object
additionalProperties: additionalProperties:
type: array type: array
nullable: true nullable: true
description: "Role flags specified here must not contradict each other"
items: items:
type: string type: string
enum: enum:
@ -584,6 +587,16 @@ spec:
- SUPERUSER - SUPERUSER
- nosuperuser - nosuperuser
- NOSUPERUSER - NOSUPERUSER
usersWithPasswordRotation:
type: array
nullable: true
items:
type: string
usersWithInPlacePasswordRotation:
type: array
nullable: true
items:
type: string
volume: volume:
type: object type: object
required: required:

View File

@ -22,8 +22,9 @@ enableJsonLogging: false
configGeneral: configGeneral:
# the deployment should create/update the CRDs # the deployment should create/update the CRDs
enable_crd_registration: true enable_crd_registration: true
# choose if deployment creates/updates CRDs with OpenAPIV3Validation # specify categories under which crds should be listed
enable_crd_validation: true crd_categories:
- "all"
# update only the statefulsets without immediately doing the rolling update # update only the statefulsets without immediately doing the rolling update
enable_lazy_spilo_upgrade: false enable_lazy_spilo_upgrade: false
# set the PGVERSION env var instead of providing the version via postgresql.bin_dir in SPILO_CONFIGURATION # set the PGVERSION env var instead of providing the version via postgresql.bin_dir in SPILO_CONFIGURATION
@ -132,6 +133,9 @@ configKubernetes:
# node_readiness_label: # node_readiness_label:
# status: ready # status: ready
# defines how nodeAffinity from manifest should be merged with node_readiness_label
# node_readiness_label_merge: "OR"
# namespaced name of the secret containing the OAuth2 token to pass to the teams API # namespaced name of the secret containing the OAuth2 token to pass to the teams API
# oauth_token_secret_name: postgresql-operator # oauth_token_secret_name: postgresql-operator

View File

@ -3,6 +3,25 @@
Learn how to configure and manage the Postgres Operator in your Kubernetes (K8s) Learn how to configure and manage the Postgres Operator in your Kubernetes (K8s)
environment. environment.
## CRD registration and validation
On startup, the operator will try to register the necessary
[CustomResourceDefinitions](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/#customresourcedefinitions)
`Postgresql` and `OperatorConfiguration`. The latter will only get created if
the `POSTGRES_OPERATOR_CONFIGURATION_OBJECT` [environment variable](https://github.com/zalando/postgres-operator/blob/master/manifests/postgres-operator.yaml#L36)
is set in the deployment yaml and is not empty. If the CRDs already exists they
will only be patched. If you do not wish the operator to create or update the
CRDs set `enable_crd_registration` config option to `false`.
CRDs are defined with a `openAPIV3Schema` structural schema against which new
manifests of [`postgresql`](https://github.com/zalando/postgres-operator/blob/master/manifests/postgresql.crd.yaml) or [`OperatorConfiguration`](https://github.com/zalando/postgres-operator/blob/master/manifests/operatorconfiguration.crd.yaml)
resources will be validated. On creation you can bypass the validation with
`kubectl create --validate=false`.
By default, the operator will register the CRDs in the `all` category so
that resources are listed on `kubectl get all` commands. The `crd_categories`
config option allows for customization of categories.
## Upgrading the operator ## Upgrading the operator
The Postgres Operator is upgraded by changing the docker image within the The Postgres Operator is upgraded by changing the docker image within the
@ -63,30 +82,6 @@ upgrade procedure, refer to the [corresponding PR in Spilo](https://github.com/z
When `major_version_upgrade_mode` is set to `manual` the operator will run When `major_version_upgrade_mode` is set to `manual` the operator will run
the upgrade script for you after the manifest is updated and pods are rotated. the upgrade script for you after the manifest is updated and pods are rotated.
## CRD Validation
[CustomResourceDefinitions](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/#customresourcedefinitions)
will be registered with schema validation by default when the operator is
deployed. The `OperatorConfiguration` CRD will only get created if the
`POSTGRES_OPERATOR_CONFIGURATION_OBJECT` [environment variable](https://github.com/zalando/postgres-operator/blob/master/manifests/postgres-operator.yaml#L36)
in the deployment yaml is set and not empty.
When submitting manifests of [`postgresql`](https://github.com/zalando/postgres-operator/blob/master/manifests/postgresql.crd.yaml) or
[`OperatorConfiguration`](https://github.com/zalando/postgres-operator/blob/master/manifests/operatorconfiguration.crd.yaml) custom
resources with kubectl, validation can be bypassed with `--validate=false`. The
operator can also be configured to not register CRDs with validation on `ADD` or
`UPDATE` events. Running instances are not affected when enabling the validation
afterwards unless the manifests is not changed then. Note, that the provided CRD
manifests contain the validation for users to understand what schema is
enforced.
Once the validation is enabled it can only be disabled manually by editing or
patching the CRD manifest:
```bash
kubectl patch crd postgresqls.acid.zalan.do -p '{"spec":{"validation": null}}'
```
## Non-default cluster domain ## Non-default cluster domain
If your cluster uses a DNS domain other than the default `cluster.local`, this If your cluster uses a DNS domain other than the default `cluster.local`, this
@ -293,6 +288,84 @@ that are aggregated into the K8s [default roles](https://kubernetes.io/docs/refe
For Helm deployments setting `rbac.createAggregateClusterRoles: true` adds these clusterroles to the deployment. For Helm deployments setting `rbac.createAggregateClusterRoles: true` adds these clusterroles to the deployment.
## Password rotation in K8s secrets
The operator regularly updates credentials in the K8s secrets if the
`enable_password_rotation` option is set to `true` in the configuration.
It happens only for `LOGIN` roles with an associated secret (manifest roles,
default users from `preparedDatabases`). Furthermore, there are the following
exceptions:
1. Infrastructure role secrets since rotation should happen by the infrastructure.
2. Team API roles that connect via OAuth2 and JWT token (no secrets to these roles anyway).
3. Database owners since ownership on database objects can not be inherited.
4. System users such as `postgres`, `standby` and `pooler` user.
The interval of days can be set with `password_rotation_interval` (default
`90` = 90 days, minimum 1). On each rotation the user name and password values
are replaced in the K8s secret. They belong to a newly created user named after
the original role plus rotation date in YYMMDD format. All priviliges are
inherited meaning that migration scripts should still grant and revoke rights
against the original role. The timestamp of the next rotation is written to the
secret as well. Note, if the rotation interval is decreased it is reflected in
the secrets only if the next rotation date is more days away than the new
length of the interval.
Pods still using the previous secret values which they keep in memory continue
to connect to the database since the password of the corresponding user is not
replaced. However, a retention policy can be configured for users created by
the password rotation feature with `password_rotation_user_retention`. The
operator will ensure that this period is at least twice as long as the
configured rotation interval, hence the default of `180` = 180 days. When
the creation date of a rotated user is older than the retention period it
might not get removed immediately. Only on the next user rotation it is checked
if users can get removed. Therefore, you might want to configure the retention
to be a multiple of the rotation interval.
### Password rotation for single users
From the configuration, password rotation is enabled for all secrets with the
mentioned exceptions. If you wish to first test rotation for a single user (or
just have it enabled only for a few secrets) you can specify it in the cluster
manifest. The rotation and retention intervals can only be configured globally.
```
spec:
usersWithSecretRotation:
- foo_user
- bar_reader_user
```
### Password replacement without extra users
For some use cases where the secret is only used rarely - think of a `flyway`
user running a migration script on pod start - we do not need to create extra
database users but can replace only the password in the K8s secret. This type
of rotation cannot be configured globally but specified in the cluster
manifest:
```
spec:
usersWithInPlaceSecretRotation:
- flyway
- bar_owner_user
```
This would be the recommended option to enable rotation in secrets of database
owners, but only if they are not used as application users for regular read
and write operations.
### Turning off password rotation
When password rotation is turned off again the operator will check if the
`username` value in the secret matches the original username and replace it
with the latter. A new password is assigned and the `nextRotation` field is
cleared. A final lookup for child (rotation) users to be removed is done but
they will only be dropped if the retention policy allows for it. This is to
avoid sudden connection issues in pods which still use credentials of these
users in memory. You have to remove these child users manually or re-enable
password rotation with smaller interval so they get cleaned up.
## Use taints and tolerations for dedicated PostgreSQL nodes ## Use taints and tolerations for dedicated PostgreSQL nodes
To ensure Postgres pods are running on nodes without any other application pods, To ensure Postgres pods are running on nodes without any other application pods,
@ -339,6 +412,81 @@ master pods from being evicted by the K8s runtime. To prevent eviction
completely, specify the toleration by leaving out the `tolerationSeconds` value completely, specify the toleration by leaving out the `tolerationSeconds` value
(similar to how Kubernetes' own DaemonSets are configured) (similar to how Kubernetes' own DaemonSets are configured)
## Node readiness labels
The operator can watch on certain node labels to detect e.g. the start of a
Kubernetes cluster upgrade procedure and move master pods off the nodes to be
decommissioned. Key-value pairs for these node readiness labels can be
specified in the configuration (option name is in singular form):
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-operator
data:
node_readiness_label: "status1:ready,status2:ready"
```
```yaml
apiVersion: "acid.zalan.do/v1"
kind: OperatorConfiguration
metadata:
name: postgresql-configuration
configuration:
kubernetes:
node_readiness_label:
status1: ready
status2: ready
```
The operator will create a `nodeAffinity` on the pods. This makes the
`node_readiness_label` option the global configuration for defining node
affinities for all Postgres clusters. You can have both, cluster-specific and
global affinity, defined and they will get merged on the pods. If
`node_readiness_label_merge` is configured to `"AND"` the node readiness
affinity will end up under the same `matchExpressions` section(s) from the
manifest affinity.
```yaml
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: environment
operator: In
values:
- pci
- key: status1
operator: In
values:
- ready
- key: status2
...
```
If `node_readiness_label_merge` is set to `"OR"` (default) the readiness label
affinty will be appended with its own expressions block:
```yaml
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: environment
...
- matchExpressions:
- key: storage
...
- matchExpressions:
- key: status1
...
- key: status2
...
```
## Enable pod anti affinity ## Enable pod anti affinity
To ensure Postgres pods are running on different topologies, you can use To ensure Postgres pods are running on different topologies, you can use

View File

@ -115,6 +115,22 @@ These parameters are grouped directly under the `spec` key in the manifest.
create the K8s secret in that namespace. The part after the first `.` is create the K8s secret in that namespace. The part after the first `.` is
considered to be the user name. Optional. considered to be the user name. Optional.
* **usersWithSecretRotation**
list of users to enable credential rotation in K8s secrets. The rotation
interval can only be configured globally. On each rotation a new user will
be added in the database replacing the `username` value in the secret of
the listed user. Although, rotation users inherit all rights from the
original role, keep in mind that ownership is not transferred. See more
details in the [administrator docs](https://github.com/zalando/postgres-operator/blob/master/docs/administrator.md#password-rotation-in-k8s-secrets).
* **usersWithInPlaceSecretRotation**
list of users to enable in-place password rotation in K8s secrets. The
rotation interval can only be configured globally. On each rotation the
password value will be replaced in the secrets which the operator reflects
in the database, too. List only users here that rarely connect to the
database, like a flyway user running a migration on Pod start. See more
details in the [administrator docs](https://github.com/zalando/postgres-operator/blob/master/docs/administrator.md#password-replacement-without-extra-users).
* **databases** * **databases**
a map of database names to database owners for the databases that should be a map of database names to database owners for the databases that should be
created by the operator. The owner users should already exist on the cluster created by the operator. The owner users should already exist on the cluster

View File

@ -75,9 +75,12 @@ Those are top-level keys, containing both leaf keys and groups.
The default is `true`. The default is `true`.
* **enable_crd_validation** * **enable_crd_validation**
toggles if the operator will create or update CRDs with *deprecated*: toggles if the operator will create or update CRDs with
[OpenAPI v3 schema validation](https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/#validation) [OpenAPI v3 schema validation](https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/#validation)
The default is `true`. The default is `true`. `false` will be ignored, since `apiextensions.io/v1` requires a structural schema definition.
* **crd_categories**
The operator will register CRDs in the `all` category by default so that they will be returned by a `kubectl get all` call. You are free to change categories or leave them empty.
* **enable_lazy_spilo_upgrade** * **enable_lazy_spilo_upgrade**
Instruct operator to update only the statefulsets with new images (Spilo and InitContainers) without immediately doing the rolling update. The assumption is pods will be re-started later with new images, for example due to the node rotation. Instruct operator to update only the statefulsets with new images (Spilo and InitContainers) without immediately doing the rolling update. The assumption is pods will be re-started later with new images, for example due to the node rotation.
@ -174,6 +177,28 @@ under the `users` key.
Postgres username used for replication between instances. The default is Postgres username used for replication between instances. The default is
`standby`. `standby`.
* **enable_password_rotation**
For all `LOGIN` roles that are not database owners the operator can rotate
credentials in the corresponding K8s secrets by replacing the username and
password. This means, new users will be added on each rotation inheriting
all priviliges from the original roles. The rotation date (in YYMMDD format)
is appended to the names of the new user. The timestamp of the next rotation
is written to the secret. The default is `false`.
* **password_rotation_interval**
If password rotation is enabled (either from config or cluster manifest) the
interval can be configured with this parameter. The measure is in days which
means daily rotation (`1`) is the most frequent interval possible.
Default is `90`.
* **password_rotation_user_retention**
To avoid an ever growing amount of new users due to password rotation the
operator will remove the created users again after a certain amount of days
has passed. The number can be configured with this parameter. However, the
operator will check that the retention policy is at least twice as long as
the rotation interval and update to this minimum in case it is not.
Default is `180`.
## Major version upgrades ## Major version upgrades
Parameters configuring automatic major version upgrades. In a Parameters configuring automatic major version upgrades. In a
@ -344,11 +369,16 @@ configuration they are grouped under the `kubernetes` key.
* **node_readiness_label** * **node_readiness_label**
a set of labels that a running and active node should possess to be a set of labels that a running and active node should possess to be
considered `ready`. The operator uses values of those labels to detect the considered `ready`. When the set is not empty, the operator assigns the
start of the Kubernetes cluster upgrade procedure and move master pods off `nodeAffinity` clause to the Postgres pods to be scheduled only on `ready`
the nodes to be decommissioned. When the set is not empty, the operator also nodes. The default is empty.
assigns the `Affinity` clause to the Postgres pods to be scheduled only on
`ready` nodes. The default is empty. * **node_readiness_label_merge**
If a `nodeAffinity` is also specified in the postgres cluster manifest
it will get merged with the `node_readiness_label` affinity on the pods.
The merge strategy can be configured - it can either be "AND" or "OR".
See [user docs](../user.md#use-taints-tolerations-and-node-affinity-for-dedicated-postgresql-nodes)
for more details. Default is "OR".
* **toleration** * **toleration**
a dictionary that should contain `key`, `operator`, `value` and a dictionary that should contain `key`, `operator`, `value` and

View File

@ -671,7 +671,9 @@ configured [default requests](reference/operator_parameters.md#kubernetes-resour
To ensure Postgres pods are running on nodes without any other application pods, To ensure Postgres pods are running on nodes without any other application pods,
you can use [taints and tolerations](https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/) you can use [taints and tolerations](https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/)
and configure the required toleration in the manifest. and configure the required toleration in the manifest. Tolerations can also be
defined in the [operator config](administrator.md#use-taints-and-tolerations-for-dedicated-postgresql-nodes)
to apply for all Postgres clusters.
```yaml ```yaml
spec: spec:
@ -703,6 +705,9 @@ spec:
- pci - pci
``` ```
If you need to define a `nodeAffinity` for all your Postgres clusters use the
`node_readiness_label` [configuration](administrator.md#node-readiness-labels).
## In-place major version upgrade ## In-place major version upgrade
Starting with Spilo 13, operator supports in-place major version upgrade to a Starting with Spilo 13, operator supports in-place major version upgrade to a

View File

@ -53,7 +53,7 @@ class K8s:
return master_pod_node, replica_pod_nodes return master_pod_node, replica_pod_nodes
def get_cluster_nodes(self, cluster_labels='cluster-name=acid-minimal-cluster', namespace='default'): def get_cluster_nodes(self, cluster_labels='application=spilo,cluster-name=acid-minimal-cluster', namespace='default'):
m = [] m = []
r = [] r = []
podsList = self.api.core_v1.list_namespaced_pod(namespace, label_selector=cluster_labels) podsList = self.api.core_v1.list_namespaced_pod(namespace, label_selector=cluster_labels)
@ -321,6 +321,9 @@ class K8s:
def get_cluster_replica_pod(self, labels='application=spilo,cluster-name=acid-minimal-cluster', namespace='default'): def get_cluster_replica_pod(self, labels='application=spilo,cluster-name=acid-minimal-cluster', namespace='default'):
return self.get_cluster_pod('replica', labels, namespace) return self.get_cluster_pod('replica', labels, namespace)
def get_secret_data(self, username, clustername='acid-minimal-cluster', namespace='default'):
return self.api.core_v1.read_namespaced_secret(
"{}.{}.credentials.postgresql.acid.zalan.do".format(username.replace("_","-"), clustername), namespace).data
class K8sBase: class K8sBase:
''' '''

View File

@ -4,8 +4,9 @@ import time
import timeout_decorator import timeout_decorator
import os import os
import yaml import yaml
import base64
from datetime import datetime from datetime import datetime, date, timedelta
from kubernetes import client from kubernetes import client
from tests.k8s_api import K8s from tests.k8s_api import K8s
@ -203,7 +204,8 @@ class EndToEndTestCase(unittest.TestCase):
"enable_postgres_team_crd": "true", "enable_postgres_team_crd": "true",
"enable_team_member_deprecation": "true", "enable_team_member_deprecation": "true",
"role_deletion_suffix": "_delete_me", "role_deletion_suffix": "_delete_me",
"resync_period": "15s" "resync_period": "15s",
"repair_period": "10s",
}, },
} }
k8s.update_config(enable_postgres_team_crd) k8s.update_config(enable_postgres_team_crd)
@ -286,7 +288,8 @@ class EndToEndTestCase(unittest.TestCase):
# revert config change # revert config change
revert_resync = { revert_resync = {
"data": { "data": {
"resync_period": "30m", "resync_period": "4m",
"repair_period": "1m",
}, },
} }
k8s.update_config(revert_resync) k8s.update_config(revert_resync)
@ -579,6 +582,7 @@ class EndToEndTestCase(unittest.TestCase):
"Parameters": None, "Parameters": None,
"AdminRole": "", "AdminRole": "",
"Origin": 2, "Origin": 2,
"IsDbOwner": False,
"Deleted": False "Deleted": False
}) })
return True return True
@ -600,7 +604,6 @@ class EndToEndTestCase(unittest.TestCase):
but lets pods run with the old image until they are recreated for but lets pods run with the old image until they are recreated for
reasons other than operator's activity. That works because the operator reasons other than operator's activity. That works because the operator
configures stateful sets to use "onDelete" pod update policy. configures stateful sets to use "onDelete" pod update policy.
The test covers: The test covers:
1) enabling lazy upgrade in existing operator deployment 1) enabling lazy upgrade in existing operator deployment
2) forcing the normal rolling upgrade by changing the operator 2) forcing the normal rolling upgrade by changing the operator
@ -695,7 +698,6 @@ class EndToEndTestCase(unittest.TestCase):
Ensure we can (a) create the cron job at user request for a specific PG cluster Ensure we can (a) create the cron job at user request for a specific PG cluster
(b) update the cluster-wide image for the logical backup pod (b) update the cluster-wide image for the logical backup pod
(c) delete the job at user request (c) delete the job at user request
Limitations: Limitations:
(a) Does not run the actual batch job because there is no S3 mock to upload backups to (a) Does not run the actual batch job because there is no S3 mock to upload backups to
(b) Assumes 'acid-minimal-cluster' exists as defined in setUp (b) Assumes 'acid-minimal-cluster' exists as defined in setUp
@ -880,12 +882,10 @@ class EndToEndTestCase(unittest.TestCase):
# verify we are in good state from potential previous tests # verify we are in good state from potential previous tests
self.eventuallyEqual(lambda: k8s.count_running_pods(), 2, "No 2 pods running") self.eventuallyEqual(lambda: k8s.count_running_pods(), 2, "No 2 pods running")
self.eventuallyEqual(lambda: len(k8s.get_patroni_running_members("acid-minimal-cluster-0")), 2, "Postgres status did not enter running")
# get nodes of master and replica(s) # get nodes of master and replica(s)
master_node, replica_nodes = k8s.get_pg_nodes(cluster_label) master_nodes, replica_nodes = k8s.get_cluster_nodes()
self.assertNotEqual(master_nodes, [])
self.assertNotEqual(master_node, [])
self.assertNotEqual(replica_nodes, []) self.assertNotEqual(replica_nodes, [])
# label node with environment=postgres # label node with environment=postgres
@ -898,8 +898,8 @@ class EndToEndTestCase(unittest.TestCase):
} }
try: try:
# patch current master node with the label # patch master node with the label
k8s.api.core_v1.patch_node(master_node, node_label_body) k8s.api.core_v1.patch_node(master_nodes[0], node_label_body)
# add node affinity to cluster # add node affinity to cluster
patch_node_affinity_config = { patch_node_affinity_config = {
@ -923,7 +923,6 @@ class EndToEndTestCase(unittest.TestCase):
} }
} }
} }
k8s.api.custom_objects_api.patch_namespaced_custom_object( k8s.api.custom_objects_api.patch_namespaced_custom_object(
group="acid.zalan.do", group="acid.zalan.do",
version="v1", version="v1",
@ -934,14 +933,17 @@ class EndToEndTestCase(unittest.TestCase):
self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync") self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync")
# node affinity change should cause replica to relocate from replica node to master node due to node affinity requirement # node affinity change should cause replica to relocate from replica node to master node due to node affinity requirement
k8s.wait_for_pod_failover(master_node, 'spilo-role=replica,' + cluster_label) k8s.wait_for_pod_failover(master_nodes, 'spilo-role=replica,' + cluster_label)
k8s.wait_for_pod_start('spilo-role=replica,' + cluster_label)
# next master will be switched over and pod needs to be replaced as well to finish the rolling update
k8s.wait_for_pod_failover(master_nodes, 'spilo-role=master,' + cluster_label)
k8s.wait_for_pod_start('spilo-role=replica,' + cluster_label) k8s.wait_for_pod_start('spilo-role=replica,' + cluster_label)
podsList = k8s.api.core_v1.list_namespaced_pod('default', label_selector=cluster_label) podsList = k8s.api.core_v1.list_namespaced_pod('default', label_selector=cluster_label)
for pod in podsList.items: for pod in podsList.items:
if pod.metadata.labels.get('spilo-role') == 'replica': if pod.metadata.labels.get('spilo-role') == 'replica':
self.assertEqual(master_node, pod.spec.node_name, self.assertEqual(master_nodes[0], pod.spec.node_name,
"Sanity check: expected replica to relocate to master node {}, but found on {}".format(master_node, pod.spec.node_name)) "Sanity check: expected replica to relocate to master node {}, but found on {}".format(master_nodes[0], pod.spec.node_name))
# check that pod has correct node affinity # check that pod has correct node affinity
key = pod.spec.affinity.node_affinity.required_during_scheduling_ignored_during_execution.node_selector_terms[0].match_expressions[0].key key = pod.spec.affinity.node_affinity.required_during_scheduling_ignored_during_execution.node_selector_terms[0].match_expressions[0].key
@ -966,15 +968,17 @@ class EndToEndTestCase(unittest.TestCase):
self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync") self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync")
# node affinity change should cause another rolling update and relocation of replica # node affinity change should cause another rolling update and relocation of replica
k8s.wait_for_pod_failover(replica_nodes, 'spilo-role=master,' + cluster_label) k8s.wait_for_pod_failover(replica_nodes, 'spilo-role=replica,' + cluster_label)
k8s.wait_for_pod_start('spilo-role=replica,' + cluster_label) k8s.wait_for_pod_start('spilo-role=replica,' + cluster_label)
except timeout_decorator.TimeoutError: except timeout_decorator.TimeoutError:
print('Operator log: {}'.format(k8s.get_operator_log())) print('Operator log: {}'.format(k8s.get_operator_log()))
raise raise
# toggle pod anti affinity to make sure replica and master run on separate nodes
self.assert_distributed_pods(replica_nodes)
@timeout_decorator.timeout(TEST_TIMEOUT_SEC) @timeout_decorator.timeout(TEST_TIMEOUT_SEC)
@unittest.skip("Skipping this test until fixed")
def test_node_readiness_label(self): def test_node_readiness_label(self):
''' '''
Remove node readiness label from master node. This must cause a failover. Remove node readiness label from master node. This must cause a failover.
@ -984,12 +988,15 @@ class EndToEndTestCase(unittest.TestCase):
readiness_label = 'lifecycle-status' readiness_label = 'lifecycle-status'
readiness_value = 'ready' readiness_value = 'ready'
try: # verify we are in good state from potential previous tests
# get nodes of master and replica(s) (expected target of new master) self.eventuallyEqual(lambda: k8s.count_running_pods(), 2, "No 2 pods running")
current_master_node, current_replica_nodes = k8s.get_pg_nodes(cluster_label)
num_replicas = len(current_replica_nodes)
failover_targets = self.get_failover_targets(current_master_node, current_replica_nodes)
# get nodes of master and replica(s) (expected target of new master)
master_nodes, replica_nodes = k8s.get_cluster_nodes()
self.assertNotEqual(master_nodes, [])
self.assertNotEqual(replica_nodes, [])
try:
# add node_readiness_label to potential failover nodes # add node_readiness_label to potential failover nodes
patch_readiness_label = { patch_readiness_label = {
"metadata": { "metadata": {
@ -998,30 +1005,43 @@ class EndToEndTestCase(unittest.TestCase):
} }
} }
} }
self.assertTrue(len(failover_targets) > 0, "No failover targets available") for replica_node in replica_nodes:
for failover_target in failover_targets: k8s.api.core_v1.patch_node(replica_node, patch_readiness_label)
k8s.api.core_v1.patch_node(failover_target, patch_readiness_label)
# define node_readiness_label in config map which should trigger a failover of the master # define node_readiness_label in config map which should trigger a rolling update
patch_readiness_label_config = { patch_readiness_label_config = {
"data": { "data": {
"node_readiness_label": readiness_label + ':' + readiness_value, "node_readiness_label": readiness_label + ':' + readiness_value,
"node_readiness_label_merge": "AND",
} }
} }
k8s.update_config(patch_readiness_label_config, "setting readiness label") k8s.update_config(patch_readiness_label_config, "setting readiness label")
new_master_node, new_replica_nodes = self.assert_failover( self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync")
current_master_node, num_replicas, failover_targets, cluster_label)
# first replica will be replaced and get the new affinity
# however, it might not start due to a volume node affinity conflict
# in this case only if the pvc and pod are deleted it can be scheduled
replica = k8s.get_cluster_replica_pod()
if replica.status.phase == 'Pending':
k8s.api.core_v1.delete_namespaced_persistent_volume_claim('pgdata-' + replica.metadata.name, 'default')
k8s.api.core_v1.delete_namespaced_pod(replica.metadata.name, 'default')
k8s.wait_for_pod_start('spilo-role=replica,' + cluster_label)
# next master will be switched over and pod needs to be replaced as well to finish the rolling update
k8s.wait_for_pod_failover(replica_nodes, 'spilo-role=master,' + cluster_label)
k8s.wait_for_pod_start('spilo-role=replica,' + cluster_label)
# patch also node where master ran before # patch also node where master ran before
k8s.api.core_v1.patch_node(current_master_node, patch_readiness_label) k8s.api.core_v1.patch_node(master_nodes[0], patch_readiness_label)
# toggle pod anti affinity to move replica away from master node
self.eventuallyTrue(lambda: self.assert_distributed_pods(new_master_node, new_replica_nodes, cluster_label), "Pods are redistributed")
except timeout_decorator.TimeoutError: except timeout_decorator.TimeoutError:
print('Operator log: {}'.format(k8s.get_operator_log())) print('Operator log: {}'.format(k8s.get_operator_log()))
raise raise
# toggle pod anti affinity to move replica away from master node
self.assert_distributed_pods(master_nodes)
@timeout_decorator.timeout(TEST_TIMEOUT_SEC) @timeout_decorator.timeout(TEST_TIMEOUT_SEC)
def test_overwrite_pooler_deployment(self): def test_overwrite_pooler_deployment(self):
k8s = self.k8s k8s = self.k8s
@ -1056,6 +1076,122 @@ class EndToEndTestCase(unittest.TestCase):
self.eventuallyEqual(lambda: k8s.count_running_pods("connection-pooler=acid-minimal-cluster-pooler"), self.eventuallyEqual(lambda: k8s.count_running_pods("connection-pooler=acid-minimal-cluster-pooler"),
0, "Pooler pods not scaled down") 0, "Pooler pods not scaled down")
@timeout_decorator.timeout(TEST_TIMEOUT_SEC)
def test_password_rotation(self):
'''
Test password rotation and removal of users due to retention policy
'''
k8s = self.k8s
leader = k8s.get_cluster_leader_pod()
today = date.today()
# enable password rotation for owner of foo database
pg_patch_inplace_rotation_for_owner = {
"spec": {
"usersWithInPlaceSecretRotation": [
"zalando"
]
}
}
k8s.api.custom_objects_api.patch_namespaced_custom_object(
"acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", pg_patch_inplace_rotation_for_owner)
self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync")
# check if next rotation date was set in secret
secret_data = k8s.get_secret_data("zalando")
next_rotation_timestamp = datetime.fromisoformat(str(base64.b64decode(secret_data["nextRotation"]), 'utf-8'))
today90days = today+timedelta(days=90)
self.assertEqual(today90days, next_rotation_timestamp.date(),
"Unexpected rotation date in secret of zalando user: expected {}, got {}".format(today90days, next_rotation_timestamp.date()))
# create fake rotation users that should be removed by operator
# but have one that would still fit into the retention period
create_fake_rotation_user = """
CREATE ROLE foo_user201031 IN ROLE foo_user;
CREATE ROLE foo_user211031 IN ROLE foo_user;
CREATE ROLE foo_user"""+(today-timedelta(days=40)).strftime("%y%m%d")+""" IN ROLE foo_user;
"""
self.query_database(leader.metadata.name, "postgres", create_fake_rotation_user)
# patch foo_user secret with outdated rotation date
fake_rotation_date = today.isoformat() + ' 00:00:00'
fake_rotation_date_encoded = base64.b64encode(fake_rotation_date.encode('utf-8'))
secret_fake_rotation = {
"data": {
"nextRotation": str(fake_rotation_date_encoded, 'utf-8'),
},
}
k8s.api.core_v1.patch_namespaced_secret(
name="foo-user.acid-minimal-cluster.credentials.postgresql.acid.zalan.do",
namespace="default",
body=secret_fake_rotation)
# enable password rotation for all other users (foo_user)
# this will force a sync of secrets for further assertions
enable_password_rotation = {
"data": {
"enable_password_rotation": "true",
"password_rotation_interval": "30",
"password_rotation_user_retention": "30", # should be set to 60
},
}
k8s.update_config(enable_password_rotation)
self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"},
"Operator does not get in sync")
# check if next rotation date and username have been replaced
secret_data = k8s.get_secret_data("foo_user")
secret_username = str(base64.b64decode(secret_data["username"]), 'utf-8')
next_rotation_timestamp = datetime.fromisoformat(str(base64.b64decode(secret_data["nextRotation"]), 'utf-8'))
rotation_user = "foo_user"+today.strftime("%y%m%d")
today30days = today+timedelta(days=30)
self.assertEqual(rotation_user, secret_username,
"Unexpected username in secret of foo_user: expected {}, got {}".format(rotation_user, secret_username))
self.assertEqual(today30days, next_rotation_timestamp.date(),
"Unexpected rotation date in secret of foo_user: expected {}, got {}".format(today30days, next_rotation_timestamp.date()))
# check if oldest fake rotation users were deleted
# there should only be foo_user, foo_user+today and foo_user+today-40days
user_query = """
SELECT rolname
FROM pg_catalog.pg_roles
WHERE rolname LIKE 'foo_user%';
"""
self.eventuallyEqual(lambda: len(self.query_database(leader.metadata.name, "postgres", user_query)), 3,
"Found incorrect number of rotation users", 10, 5)
# disable password rotation for all other users (foo_user)
# and pick smaller intervals to see if the third fake rotation user is dropped
enable_password_rotation = {
"data": {
"enable_password_rotation": "false",
"password_rotation_interval": "15",
"password_rotation_user_retention": "30", # 2 * rotation interval
},
}
k8s.update_config(enable_password_rotation)
self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"},
"Operator does not get in sync")
# check if username in foo_user secret is reset
secret_data = k8s.get_secret_data("foo_user")
secret_username = str(base64.b64decode(secret_data["username"]), 'utf-8')
next_rotation_timestamp = str(base64.b64decode(secret_data["nextRotation"]), 'utf-8')
self.assertEqual("foo_user", secret_username,
"Unexpected username in secret of foo_user: expected {}, got {}".format("foo_user", secret_username))
self.assertEqual('', next_rotation_timestamp,
"Unexpected rotation date in secret of foo_user: expected empty string, got {}".format(next_rotation_timestamp))
# check roles again, there should only be foo_user and foo_user+today
user_query = """
SELECT rolname
FROM pg_catalog.pg_roles
WHERE rolname LIKE 'foo_user%';
"""
self.eventuallyEqual(lambda: len(self.query_database(leader.metadata.name, "postgres", user_query)), 2,
"Found incorrect number of rotation users", 10, 5)
@timeout_decorator.timeout(TEST_TIMEOUT_SEC) @timeout_decorator.timeout(TEST_TIMEOUT_SEC)
def test_patroni_config_update(self): def test_patroni_config_update(self):
''' '''
@ -1269,6 +1405,7 @@ class EndToEndTestCase(unittest.TestCase):
"data": { "data": {
"pod_label_wait_timeout": "2s", "pod_label_wait_timeout": "2s",
"resync_period": "30s", "resync_period": "30s",
"repair_period": "10s",
} }
} }
@ -1309,7 +1446,8 @@ class EndToEndTestCase(unittest.TestCase):
patch_resync_config = { patch_resync_config = {
"data": { "data": {
"pod_label_wait_timeout": "10m", "pod_label_wait_timeout": "10m",
"resync_period": "30m", "resync_period": "4m",
"repair_period": "2m",
} }
} }
k8s.update_config(patch_resync_config, "revert resync interval and pod_label_wait_timeout") k8s.update_config(patch_resync_config, "revert resync interval and pod_label_wait_timeout")
@ -1413,7 +1551,6 @@ class EndToEndTestCase(unittest.TestCase):
self.eventuallyTrue(lambda: k8s.check_statefulset_annotations(cluster_label, annotations), "Annotations missing") self.eventuallyTrue(lambda: k8s.check_statefulset_annotations(cluster_label, annotations), "Annotations missing")
@timeout_decorator.timeout(TEST_TIMEOUT_SEC) @timeout_decorator.timeout(TEST_TIMEOUT_SEC)
@unittest.skip("Skipping this test until fixed")
def test_taint_based_eviction(self): def test_taint_based_eviction(self):
''' '''
Add taint "postgres=:NoExecute" to node with master. This must cause a failover. Add taint "postgres=:NoExecute" to node with master. This must cause a failover.
@ -1427,7 +1564,6 @@ class EndToEndTestCase(unittest.TestCase):
# get nodes of master and replica(s) (expected target of new master) # get nodes of master and replica(s) (expected target of new master)
master_nodes, replica_nodes = k8s.get_cluster_nodes() master_nodes, replica_nodes = k8s.get_cluster_nodes()
self.assertNotEqual(master_nodes, []) self.assertNotEqual(master_nodes, [])
self.assertNotEqual(replica_nodes, []) self.assertNotEqual(replica_nodes, [])
@ -1442,10 +1578,7 @@ class EndToEndTestCase(unittest.TestCase):
] ]
} }
} }
k8s.api.core_v1.patch_node(master_nodes[0], body) k8s.api.core_v1.patch_node(master_nodes[0], body)
self.eventuallyTrue(lambda: k8s.get_cluster_nodes()[0], replica_nodes)
self.assertNotEqual(lambda: k8s.get_cluster_nodes()[0], master_nodes)
# add toleration to pods # add toleration to pods
patch_toleration_config = { patch_toleration_config = {
@ -1454,15 +1587,20 @@ class EndToEndTestCase(unittest.TestCase):
} }
} }
k8s.update_config(patch_toleration_config, step="allow tainted nodes") try:
k8s.update_config(patch_toleration_config, step="allow tainted nodes")
self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"},
"Operator does not get in sync")
self.eventuallyEqual(lambda: k8s.count_running_pods(), 2, "No 2 pods running") self.eventuallyEqual(lambda: k8s.count_running_pods(), 2, "No 2 pods running")
self.eventuallyEqual(lambda: len(k8s.get_patroni_running_members("acid-minimal-cluster-0")), 2, "Postgres status did not enter running") self.eventuallyEqual(lambda: len(k8s.get_patroni_running_members("acid-minimal-cluster-0")), 2, "Postgres status did not enter running")
except timeout_decorator.TimeoutError:
print('Operator log: {}'.format(k8s.get_operator_log()))
raise
# toggle pod anti affinity to move replica away from master node # toggle pod anti affinity to move replica away from master node
nm, new_replica_nodes = k8s.get_cluster_nodes() self.assert_distributed_pods(master_nodes)
new_master_node = nm[0]
self.assert_distributed_pods(new_master_node, new_replica_nodes, cluster_label)
@timeout_decorator.timeout(TEST_TIMEOUT_SEC) @timeout_decorator.timeout(TEST_TIMEOUT_SEC)
def test_zz_cluster_deletion(self): def test_zz_cluster_deletion(self):
@ -1549,39 +1687,6 @@ class EndToEndTestCase(unittest.TestCase):
} }
k8s.update_config(patch_delete_annotations) k8s.update_config(patch_delete_annotations)
def get_failover_targets(self, master_node, replica_nodes):
'''
If all pods live on the same node, failover will happen to other worker(s)
'''
k8s = self.k8s
k8s_master_exclusion = 'kubernetes.io/hostname!=postgres-operator-e2e-tests-control-plane'
failover_targets = [x for x in replica_nodes if x != master_node]
if len(failover_targets) == 0:
nodes = k8s.api.core_v1.list_node(label_selector=k8s_master_exclusion)
for n in nodes.items:
if n.metadata.name != master_node:
failover_targets.append(n.metadata.name)
return failover_targets
def assert_failover(self, current_master_node, num_replicas, failover_targets, cluster_label):
'''
Check if master is failing over. The replica should move first to be the switchover target
'''
k8s = self.k8s
k8s.wait_for_pod_failover(failover_targets, 'spilo-role=master,' + cluster_label)
k8s.wait_for_pod_start('spilo-role=replica,' + cluster_label)
new_master_node, new_replica_nodes = k8s.get_pg_nodes(cluster_label)
self.assertNotEqual(current_master_node, new_master_node,
"Master on {} did not fail over to one of {}".format(current_master_node, failover_targets))
self.assertEqual(num_replicas, len(new_replica_nodes),
"Expected {} replicas, found {}".format(num_replicas, len(new_replica_nodes)))
self.assert_master_is_unique()
return new_master_node, new_replica_nodes
def assert_master_is_unique(self, namespace='default', clusterName="acid-minimal-cluster"): def assert_master_is_unique(self, namespace='default', clusterName="acid-minimal-cluster"):
''' '''
Check that there is a single pod in the k8s cluster with the label "spilo-role=master" Check that there is a single pod in the k8s cluster with the label "spilo-role=master"
@ -1593,14 +1698,23 @@ class EndToEndTestCase(unittest.TestCase):
num_of_master_pods = k8s.count_pods_with_label(labels, namespace) num_of_master_pods = k8s.count_pods_with_label(labels, namespace)
self.assertEqual(num_of_master_pods, 1, "Expected 1 master pod, found {}".format(num_of_master_pods)) self.assertEqual(num_of_master_pods, 1, "Expected 1 master pod, found {}".format(num_of_master_pods))
def assert_distributed_pods(self, master_node, replica_nodes, cluster_label): @timeout_decorator.timeout(TEST_TIMEOUT_SEC)
def assert_distributed_pods(self, target_nodes, cluster_labels='cluster-name=acid-minimal-cluster'):
''' '''
Other tests can lead to the situation that master and replica are on the same node. Other tests can lead to the situation that master and replica are on the same node.
Toggle pod anti affinty to distribute pods accross nodes (replica in particular). Toggle pod anti affinty to distribute pods accross nodes (replica in particular).
''' '''
k8s = self.k8s k8s = self.k8s
cluster_label = 'application=spilo,cluster-name=acid-minimal-cluster' cluster_labels = 'application=spilo,cluster-name=acid-minimal-cluster'
failover_targets = self.get_failover_targets(master_node, replica_nodes)
# get nodes of master and replica(s)
master_nodes, replica_nodes = k8s.get_cluster_nodes()
self.assertNotEqual(master_nodes, [])
self.assertNotEqual(replica_nodes, [])
# if nodes are different we can quit here
if master_nodes[0] not in replica_nodes:
return True
# enable pod anti affintiy in config map which should trigger movement of replica # enable pod anti affintiy in config map which should trigger movement of replica
patch_enable_antiaffinity = { patch_enable_antiaffinity = {
@ -1608,18 +1722,40 @@ class EndToEndTestCase(unittest.TestCase):
"enable_pod_antiaffinity": "true" "enable_pod_antiaffinity": "true"
} }
} }
k8s.update_config(patch_enable_antiaffinity, "enable antiaffinity")
self.assert_failover(master_node, len(replica_nodes), failover_targets, cluster_label)
# now disable pod anti affintiy again which will cause yet another failover try:
patch_disable_antiaffinity = { k8s.update_config(patch_enable_antiaffinity, "enable antiaffinity")
"data": { self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync")
"enable_pod_antiaffinity": "false"
k8s.wait_for_pod_start('spilo-role=replica,' + cluster_labels)
k8s.wait_for_running_pods(cluster_labels, 2)
# now disable pod anti affintiy again which will cause yet another failover
patch_disable_antiaffinity = {
"data": {
"enable_pod_antiaffinity": "false"
}
} }
} k8s.update_config(patch_disable_antiaffinity, "disable antiaffinity")
k8s.update_config(patch_disable_antiaffinity, "disable antiaffinity") self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, "Operator does not get in sync")
k8s.wait_for_pod_start('spilo-role=master,' + cluster_label)
k8s.wait_for_pod_start('spilo-role=replica,' + cluster_label) k8s.wait_for_pod_start('spilo-role=replica,' + cluster_labels)
k8s.wait_for_running_pods(cluster_labels, 2)
master_nodes, replica_nodes = k8s.get_cluster_nodes()
self.assertNotEqual(master_nodes, [])
self.assertNotEqual(replica_nodes, [])
# if nodes are different we can quit here
for target_node in target_nodes:
if (target_node not in master_nodes or target_node not in replica_nodes) and master_nodes[0] in replica_nodes:
print('Pods run on the same node')
return False
except timeout_decorator.TimeoutError:
print('Operator log: {}'.format(k8s.get_operator_log()))
raise
return True return True
def list_databases(self, pod_name): def list_databases(self, pod_name):

View File

@ -17,6 +17,9 @@ spec:
zalando: zalando:
- superuser - superuser
- createdb - createdb
foo_user: []
# usersWithSecretRotation: "foo_user"
# usersWithInPlaceSecretRotation: "flyway,bar_owner_user"
enableMasterLoadBalancer: false enableMasterLoadBalancer: false
enableReplicaLoadBalancer: false enableReplicaLoadBalancer: false
enableConnectionPooler: false # enable/disable connection pooler deployment enableConnectionPooler: false # enable/disable connection pooler deployment

View File

@ -22,6 +22,7 @@ data:
# connection_pooler_number_of_instances: 2 # connection_pooler_number_of_instances: 2
# connection_pooler_schema: "pooler" # connection_pooler_schema: "pooler"
# connection_pooler_user: "pooler" # connection_pooler_user: "pooler"
crd_categories: "all"
# custom_service_annotations: "keyx:valuez,keya:valuea" # custom_service_annotations: "keyx:valuez,keya:valuea"
# custom_pod_annotations: "keya:valuea,keyb:valueb" # custom_pod_annotations: "keya:valuea,keyb:valueb"
db_hosted_zone: db.example.com db_hosted_zone: db.example.com
@ -36,7 +37,6 @@ data:
# downscaler_annotations: "deployment-time,downscaler/*" # downscaler_annotations: "deployment-time,downscaler/*"
# enable_admin_role_for_users: "true" # enable_admin_role_for_users: "true"
# enable_crd_registration: "true" # enable_crd_registration: "true"
# enable_crd_validation: "true"
# enable_cross_namespace_secret: "false" # enable_cross_namespace_secret: "false"
# enable_database_access: "true" # enable_database_access: "true"
enable_ebs_gp3_migration: "false" enable_ebs_gp3_migration: "false"
@ -44,6 +44,7 @@ data:
# enable_init_containers: "true" # enable_init_containers: "true"
# enable_lazy_spilo_upgrade: "false" # enable_lazy_spilo_upgrade: "false"
enable_master_load_balancer: "false" enable_master_load_balancer: "false"
enable_password_rotation: "false"
enable_pgversion_env_var: "true" enable_pgversion_env_var: "true"
# enable_pod_antiaffinity: "false" # enable_pod_antiaffinity: "false"
# enable_pod_disruption_budget: "true" # enable_pod_disruption_budget: "true"
@ -86,11 +87,14 @@ data:
# min_cpu_limit: 250m # min_cpu_limit: 250m
# min_memory_limit: 250Mi # min_memory_limit: 250Mi
# minimal_major_version: "9.6" # minimal_major_version: "9.6"
# node_readiness_label: "" # node_readiness_label: "status:ready"
# node_readiness_label_merge: "OR"
# oauth_token_secret_name: postgresql-operator # oauth_token_secret_name: postgresql-operator
# pam_configuration: | # pam_configuration: |
# https://info.example.com/oauth2/tokeninfo?access_token= uid realm=/employees # https://info.example.com/oauth2/tokeninfo?access_token= uid realm=/employees
# pam_role_name: zalandos # pam_role_name: zalandos
# password_rotation_interval: "90"
# password_rotation_user_retention: "180"
pdb_name_format: "postgres-{cluster}-pdb" pdb_name_format: "postgres-{cluster}-pdb"
# pod_antiaffinity_topology_key: "kubernetes.io/hostname" # pod_antiaffinity_topology_key: "kubernetes.io/hostname"
pod_deletion_wait_timeout: 10m pod_deletion_wait_timeout: 10m

View File

@ -59,6 +59,11 @@ spec:
configuration: configuration:
type: object type: object
properties: properties:
crd_categories:
type: array
nullable: true
items:
type: string
docker_image: docker_image:
type: string type: string
default: "registry.opensource.zalan.do/acid/spilo-14:2.1-p3" default: "registry.opensource.zalan.do/acid/spilo-14:2.1-p3"
@ -67,6 +72,7 @@ spec:
default: true default: true
enable_crd_validation: enable_crd_validation:
type: boolean type: boolean
description: deprecated
default: true default: true
enable_lazy_spilo_upgrade: enable_lazy_spilo_upgrade:
type: boolean type: boolean
@ -88,11 +94,13 @@ spec:
default: false default: false
max_instances: max_instances:
type: integer type: integer
minimum: -1 # -1 = disabled description: "-1 = disabled"
minimum: -1
default: -1 default: -1
min_instances: min_instances:
type: integer type: integer
minimum: -1 # -1 = disabled description: "-1 = disabled"
minimum: -1
default: -1 default: -1
resync_period: resync_period:
type: string type: string
@ -120,6 +128,15 @@ spec:
users: users:
type: object type: object
properties: properties:
enable_password_rotation:
type: boolean
default: false
password_rotation_interval:
type: integer
default: 90
password_rotation_user_retention:
type: integer
default: 180
replication_username: replication_username:
type: string type: string
default: standby default: standby
@ -173,6 +190,9 @@ spec:
type: array type: array
items: items:
type: string type: string
enable_cross_namespace_secret:
type: boolean
default: false
enable_init_containers: enable_init_containers:
type: boolean type: boolean
default: true default: true
@ -228,6 +248,11 @@ spec:
type: object type: object
additionalProperties: additionalProperties:
type: string type: string
node_readiness_label_merge:
type: string
enum:
- "AND"
- "OR"
oauth_token_secret_name: oauth_token_secret_name:
type: string type: string
default: "postgresql-operator" default: "postgresql-operator"

View File

@ -5,7 +5,8 @@ metadata:
configuration: configuration:
docker_image: registry.opensource.zalan.do/acid/spilo-14:2.1-p3 docker_image: registry.opensource.zalan.do/acid/spilo-14:2.1-p3
# enable_crd_registration: true # enable_crd_registration: true
# enable_crd_validation: true # crd_categories:
# - all
# enable_lazy_spilo_upgrade: false # enable_lazy_spilo_upgrade: false
enable_pgversion_env_var: true enable_pgversion_env_var: true
# enable_shm_volume: true # enable_shm_volume: true
@ -25,6 +26,9 @@ configuration:
# protocol: TCP # protocol: TCP
workers: 8 workers: 8
users: users:
enable_password_rotation: false
password_rotation_interval: 90
password_rotation_user_retention: 180
replication_username: standby replication_username: standby
super_username: postgres super_username: postgres
major_version_upgrade: major_version_upgrade:
@ -70,6 +74,7 @@ configuration:
master_pod_move_timeout: 20m master_pod_move_timeout: 20m
# node_readiness_label: # node_readiness_label:
# status: ready # status: ready
# node_readiness_label_merge: "OR"
oauth_token_secret_name: postgresql-operator oauth_token_secret_name: postgresql-operator
pdb_name_format: "postgres-{cluster}-pdb" pdb_name_format: "postgres-{cluster}-pdb"
pod_antiaffinity_topology_key: "kubernetes.io/hostname" pod_antiaffinity_topology_key: "kubernetes.io/hostname"

View File

@ -145,7 +145,7 @@ spec:
- "transaction" - "transaction"
numberOfInstances: numberOfInstances:
type: integer type: integer
minimum: 2 minimum: 1
resources: resources:
type: object type: object
required: required:
@ -199,8 +199,9 @@ spec:
type: boolean type: boolean
enableShmVolume: enableShmVolume:
type: boolean type: boolean
init_containers: # deprecated init_containers:
type: array type: array
description: deprecated
nullable: true nullable: true
items: items:
type: object type: object
@ -227,8 +228,8 @@ spec:
items: items:
type: object type: object
required: required:
- weight
- preference - preference
- weight
properties: properties:
preference: preference:
type: object type: object
@ -346,8 +347,9 @@ spec:
type: object type: object
additionalProperties: additionalProperties:
type: string type: string
pod_priority_class_name: # deprecated pod_priority_class_name:
type: string type: string
description: deprecated
podPriorityClassName: podPriorityClassName:
type: string type: string
postgresql: postgresql:
@ -391,8 +393,9 @@ spec:
type: boolean type: boolean
secretNamespace: secretNamespace:
type: string type: string
replicaLoadBalancer: # deprecated replicaLoadBalancer:
type: boolean type: boolean
description: deprecated
resources: resources:
type: object type: object
required: required:
@ -543,14 +546,14 @@ spec:
- PreferNoSchedule - PreferNoSchedule
tolerationSeconds: tolerationSeconds:
type: integer type: integer
useLoadBalancer: # deprecated useLoadBalancer:
type: boolean type: boolean
description: deprecated
users: users:
type: object type: object
additionalProperties: additionalProperties:
type: array type: array
nullable: true nullable: true
description: "Role flags specified here must not contradict each other"
items: items:
type: string type: string
enum: enum:
@ -582,6 +585,16 @@ spec:
- SUPERUSER - SUPERUSER
- nosuperuser - nosuperuser
- NOSUPERUSER - NOSUPERUSER
usersWithPasswordRotation:
type: array
nullable: true
items:
type: string
usersWithInPlacePasswordRotation:
type: array
nullable: true
items:
type: string
volume: volume:
type: object type: object
required: required:

View File

@ -1,6 +1,8 @@
package v1 package v1
import ( import (
"fmt"
acidzalando "github.com/zalando/postgres-operator/pkg/apis/acid.zalan.do" acidzalando "github.com/zalando/postgres-operator/pkg/apis/acid.zalan.do"
"github.com/zalando/postgres-operator/pkg/util" "github.com/zalando/postgres-operator/pkg/util"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
@ -11,11 +13,13 @@ import (
const ( const (
PostgresCRDResourceKind = "postgresql" PostgresCRDResourceKind = "postgresql"
PostgresCRDResourcePlural = "postgresqls" PostgresCRDResourcePlural = "postgresqls"
PostgresCRDResourceList = PostgresCRDResourceKind + "List"
PostgresCRDResouceName = PostgresCRDResourcePlural + "." + acidzalando.GroupName PostgresCRDResouceName = PostgresCRDResourcePlural + "." + acidzalando.GroupName
PostgresCRDResourceShort = "pg" PostgresCRDResourceShort = "pg"
OperatorConfigCRDResouceKind = "OperatorConfiguration" OperatorConfigCRDResouceKind = "OperatorConfiguration"
OperatorConfigCRDResourcePlural = "operatorconfigurations" OperatorConfigCRDResourcePlural = "operatorconfigurations"
OperatorConfigCRDResourceList = OperatorConfigCRDResouceKind + "List"
OperatorConfigCRDResourceName = OperatorConfigCRDResourcePlural + "." + acidzalando.GroupName OperatorConfigCRDResourceName = OperatorConfigCRDResourcePlural + "." + acidzalando.GroupName
OperatorConfigCRDResourceShort = "opconfig" OperatorConfigCRDResourceShort = "opconfig"
) )
@ -148,7 +152,8 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Type: "string", Type: "string",
}, },
"targetContainers": { "targetContainers": {
Type: "array", Type: "array",
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "string", Type: "string",
@ -199,9 +204,8 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Type: "string", Type: "string",
}, },
"timestamp": { "timestamp": {
Type: "string", Type: "string",
Description: "Date-time format that specifies a timezone as an offset relative to UTC e.g. 1996-12-19T16:39:57-08:00", Pattern: "^([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\\.[0-9]+)?(([+-]([01][0-9]|2[0-3]):[0-5][0-9]))$",
Pattern: "^([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\\.[0-9]+)?(([+-]([01][0-9]|2[0-3]):[0-5][0-9]))$",
}, },
"uid": { "uid": {
Type: "string", Type: "string",
@ -242,14 +246,12 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Required: []string{"cpu", "memory"}, Required: []string{"cpu", "memory"},
Properties: map[string]apiextv1.JSONSchemaProps{ Properties: map[string]apiextv1.JSONSchemaProps{
"cpu": { "cpu": {
Type: "string", Type: "string",
Description: "Decimal natural followed by m, or decimal natural followed by dot followed by up to three decimal digits (precision used by Kubernetes). Must be greater than 0", Pattern: "^(\\d+m|\\d+(\\.\\d{1,3})?)$",
Pattern: "^(\\d+m|\\d+(\\.\\d{1,3})?)$",
}, },
"memory": { "memory": {
Type: "string", Type: "string",
Description: "Plain integer or fixed-point integer using one of these suffixes: E, P, T, G, M, k (with or without a tailing i). Must be greater than 0", Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
}, },
}, },
}, },
@ -258,14 +260,12 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Required: []string{"cpu", "memory"}, Required: []string{"cpu", "memory"},
Properties: map[string]apiextv1.JSONSchemaProps{ Properties: map[string]apiextv1.JSONSchemaProps{
"cpu": { "cpu": {
Type: "string", Type: "string",
Description: "Decimal natural followed by m, or decimal natural followed by dot followed by up to three decimal digits (precision used by Kubernetes). Must be greater than 0", Pattern: "^(\\d+m|\\d+(\\.\\d{1,3})?)$",
Pattern: "^(\\d+m|\\d+(\\.\\d{1,3})?)$",
}, },
"memory": { "memory": {
Type: "string", Type: "string",
Description: "Plain integer or fixed-point integer using one of these suffixes: E, P, T, G, M, k (with or without a tailing i). Must be greater than 0", Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
}, },
}, },
}, },
@ -283,8 +283,7 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Type: "object", Type: "object",
AdditionalProperties: &apiextv1.JSONSchemaPropsOrBool{ AdditionalProperties: &apiextv1.JSONSchemaPropsOrBool{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "string", Type: "string",
Description: "User names specified here as database owners must be declared in the users key of the spec key",
}, },
}, },
}, },
@ -311,7 +310,8 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
"init_containers": { "init_containers": {
Type: "array", Type: "array",
Description: "Deprecated", Description: "deprecated",
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "object", Type: "object",
@ -320,7 +320,8 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
}, },
"initContainers": { "initContainers": {
Type: "array", Type: "array",
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "object", Type: "object",
@ -358,9 +359,23 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Type: "array", Type: "array",
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "object", Type: "object",
AdditionalProperties: &apiextv1.JSONSchemaPropsOrBool{ Required: []string{"key", "operator"},
Allows: true, Properties: map[string]apiextv1.JSONSchemaProps{
"key": {
Type: "string",
},
"operator": {
Type: "string",
},
"values": {
Type: "array",
Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{
Type: "string",
},
},
},
}, },
}, },
}, },
@ -369,9 +384,23 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Type: "array", Type: "array",
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "object", Type: "object",
AdditionalProperties: &apiextv1.JSONSchemaPropsOrBool{ Required: []string{"key", "operator"},
Allows: true, Properties: map[string]apiextv1.JSONSchemaProps{
"key": {
Type: "string",
},
"operator": {
Type: "string",
},
"values": {
Type: "array",
Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{
Type: "string",
},
},
},
}, },
}, },
}, },
@ -400,9 +429,23 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Type: "array", Type: "array",
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "object", Type: "object",
AdditionalProperties: &apiextv1.JSONSchemaPropsOrBool{ Required: []string{"key", "operator"},
Allows: true, Properties: map[string]apiextv1.JSONSchemaProps{
"key": {
Type: "string",
},
"operator": {
Type: "string",
},
"values": {
Type: "array",
Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{
Type: "string",
},
},
},
}, },
}, },
}, },
@ -411,9 +454,23 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Type: "array", Type: "array",
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "object", Type: "object",
AdditionalProperties: &apiextv1.JSONSchemaPropsOrBool{ Required: []string{"key", "operator"},
Allows: true, Properties: map[string]apiextv1.JSONSchemaProps{
"key": {
Type: "string",
},
"operator": {
Type: "string",
},
"values": {
Type: "array",
Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{
Type: "string",
},
},
},
}, },
}, },
}, },
@ -492,7 +549,7 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
"pod_priority_class_name": { "pod_priority_class_name": {
Type: "string", Type: "string",
Description: "Deprecated", Description: "deprecated",
}, },
"podPriorityClassName": { "podPriorityClassName": {
Type: "string", Type: "string",
@ -579,7 +636,7 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
"replicaLoadBalancer": { "replicaLoadBalancer": {
Type: "boolean", Type: "boolean",
Description: "Deprecated", Description: "deprecated",
}, },
"resources": { "resources": {
Type: "object", Type: "object",
@ -590,14 +647,12 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Required: []string{"cpu", "memory"}, Required: []string{"cpu", "memory"},
Properties: map[string]apiextv1.JSONSchemaProps{ Properties: map[string]apiextv1.JSONSchemaProps{
"cpu": { "cpu": {
Type: "string", Type: "string",
Description: "Decimal natural followed by m, or decimal natural followed by dot followed by up to three decimal digits (precision used by Kubernetes). Must be greater than 0", Pattern: "^(\\d+m|\\d+(\\.\\d{1,3})?)$",
Pattern: "^(\\d+m|\\d+(\\.\\d{1,3})?)$",
}, },
"memory": { "memory": {
Type: "string", Type: "string",
Description: "Plain integer or fixed-point integer using one of these suffixes: E, P, T, G, M, k (with or without a tailing i). Must be greater than 0", Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
}, },
}, },
}, },
@ -606,14 +661,12 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
Required: []string{"cpu", "memory"}, Required: []string{"cpu", "memory"},
Properties: map[string]apiextv1.JSONSchemaProps{ Properties: map[string]apiextv1.JSONSchemaProps{
"cpu": { "cpu": {
Type: "string", Type: "string",
Description: "Decimal natural followed by m, or decimal natural followed by dot followed by up to three decimal digits (precision used by Kubernetes). Must be greater than 0", Pattern: "^(\\d+m|\\d+(\\.\\d{1,3})?)$",
Pattern: "^(\\d+m|\\d+(\\.\\d{1,3})?)$",
}, },
"memory": { "memory": {
Type: "string", Type: "string",
Description: "Plain integer or fixed-point integer using one of these suffixes: E, P, T, G, M, k (with or without a tailing i). Must be greater than 0", Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
}, },
}, },
}, },
@ -631,7 +684,8 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
}, },
"sidecars": { "sidecars": {
Type: "array", Type: "array",
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "object", Type: "object",
@ -778,15 +832,14 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
"useLoadBalancer": { "useLoadBalancer": {
Type: "boolean", Type: "boolean",
Description: "Deprecated", Description: "deprecated",
}, },
"users": { "users": {
Type: "object", Type: "object",
AdditionalProperties: &apiextv1.JSONSchemaPropsOrBool{ AdditionalProperties: &apiextv1.JSONSchemaPropsOrBool{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "array", Type: "array",
Description: "Role flags specified here must not contradict each other", Nullable: true,
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "string", Type: "string",
@ -881,6 +934,24 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
}, },
}, },
"usersWithSecretRotation": {
Type: "array",
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{
Type: "string",
},
},
},
"usersWithInPlaceSecretRotation": {
Type: "array",
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{
Type: "string",
},
},
},
"volume": { "volume": {
Type: "object", Type: "object",
Required: []string{"size"}, Required: []string{"size"},
@ -937,9 +1008,8 @@ var PostgresCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
}, },
"size": { "size": {
Type: "string", Type: "string",
Description: "Value must not be zero", Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
Pattern: "^(\\d+(e\\d+)?|\\d+(\\.\\d+)?(e\\d+)?[EPTGMK]i?)$",
}, },
"storageClass": { "storageClass": {
Type: "string", Type: "string",
@ -991,6 +1061,15 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{
"configuration": { "configuration": {
Type: "object", Type: "object",
Properties: map[string]apiextv1.JSONSchemaProps{ Properties: map[string]apiextv1.JSONSchemaProps{
"crd_categories": {
Type: "array",
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{
Type: "string",
},
},
},
"docker_image": { "docker_image": {
Type: "string", Type: "string",
}, },
@ -998,7 +1077,8 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{
Type: "boolean", Type: "boolean",
}, },
"enable_crd_validation": { "enable_crd_validation": {
Type: "boolean", Type: "boolean",
Description: "deprecated",
}, },
"enable_lazy_spilo_upgrade": { "enable_lazy_spilo_upgrade": {
Type: "boolean", Type: "boolean",
@ -1043,7 +1123,8 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
}, },
"sidecars": { "sidecars": {
Type: "array", Type: "array",
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "object", Type: "object",
@ -1154,7 +1235,8 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{
Type: "string", Type: "string",
}, },
"infrastructure_roles_secrets": { "infrastructure_roles_secrets": {
Type: "array", Type: "array",
Nullable: true,
Items: &apiextv1.JSONSchemaPropsOrArray{ Items: &apiextv1.JSONSchemaPropsOrArray{
Schema: &apiextv1.JSONSchemaProps{ Schema: &apiextv1.JSONSchemaProps{
Type: "object", Type: "object",
@ -1215,6 +1297,17 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
}, },
}, },
"node_readiness_label_merge": {
Type: "string",
Enum: []apiextv1.JSON{
{
Raw: []byte(`"AND"`),
},
{
Raw: []byte(`"OR"`),
},
},
},
"oauth_token_secret_name": { "oauth_token_secret_name": {
Type: "string", Type: "string",
}, },
@ -1645,18 +1738,27 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{
}, },
} }
func buildCRD(name, kind, plural, short string, columns []apiextv1.CustomResourceColumnDefinition, validation apiextv1.CustomResourceValidation) *apiextv1.CustomResourceDefinition { func buildCRD(name, kind, plural, list, short string,
categories []string,
columns []apiextv1.CustomResourceColumnDefinition,
validation apiextv1.CustomResourceValidation) *apiextv1.CustomResourceDefinition {
return &apiextv1.CustomResourceDefinition{ return &apiextv1.CustomResourceDefinition{
TypeMeta: metav1.TypeMeta{
APIVersion: fmt.Sprintf("%s/%s", apiextv1.GroupName, apiextv1.SchemeGroupVersion.Version),
Kind: "CustomResourceDefinition",
},
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: name, Name: name,
}, },
Spec: apiextv1.CustomResourceDefinitionSpec{ Spec: apiextv1.CustomResourceDefinitionSpec{
Group: SchemeGroupVersion.Group, Group: SchemeGroupVersion.Group,
Names: apiextv1.CustomResourceDefinitionNames{ Names: apiextv1.CustomResourceDefinitionNames{
Plural: plural,
ShortNames: []string{short},
Kind: kind, Kind: kind,
Categories: []string{"all"}, ListKind: list,
Plural: plural,
Singular: kind,
ShortNames: []string{short},
Categories: categories,
}, },
Scope: apiextv1.NamespaceScoped, Scope: apiextv1.NamespaceScoped,
Versions: []apiextv1.CustomResourceDefinitionVersion{ Versions: []apiextv1.CustomResourceDefinitionVersion{
@ -1676,33 +1778,25 @@ func buildCRD(name, kind, plural, short string, columns []apiextv1.CustomResourc
} }
// PostgresCRD returns CustomResourceDefinition built from PostgresCRDResource // PostgresCRD returns CustomResourceDefinition built from PostgresCRDResource
func PostgresCRD(enableValidation *bool) *apiextv1.CustomResourceDefinition { func PostgresCRD(crdCategories []string) *apiextv1.CustomResourceDefinition {
postgresCRDvalidation := apiextv1.CustomResourceValidation{}
if enableValidation != nil && *enableValidation {
postgresCRDvalidation = PostgresCRDResourceValidation
}
return buildCRD(PostgresCRDResouceName, return buildCRD(PostgresCRDResouceName,
PostgresCRDResourceKind, PostgresCRDResourceKind,
PostgresCRDResourcePlural, PostgresCRDResourcePlural,
PostgresCRDResourceList,
PostgresCRDResourceShort, PostgresCRDResourceShort,
crdCategories,
PostgresCRDResourceColumns, PostgresCRDResourceColumns,
postgresCRDvalidation) PostgresCRDResourceValidation)
} }
// ConfigurationCRD returns CustomResourceDefinition built from OperatorConfigCRDResource // ConfigurationCRD returns CustomResourceDefinition built from OperatorConfigCRDResource
func ConfigurationCRD(enableValidation *bool) *apiextv1.CustomResourceDefinition { func ConfigurationCRD(crdCategories []string) *apiextv1.CustomResourceDefinition {
opconfigCRDvalidation := apiextv1.CustomResourceValidation{}
if enableValidation != nil && *enableValidation {
opconfigCRDvalidation = OperatorConfigCRDResourceValidation
}
return buildCRD(OperatorConfigCRDResourceName, return buildCRD(OperatorConfigCRDResourceName,
OperatorConfigCRDResouceKind, OperatorConfigCRDResouceKind,
OperatorConfigCRDResourcePlural, OperatorConfigCRDResourcePlural,
OperatorConfigCRDResourceList,
OperatorConfigCRDResourceShort, OperatorConfigCRDResourceShort,
crdCategories,
OperatorConfigCRDResourceColumns, OperatorConfigCRDResourceColumns,
opconfigCRDvalidation) OperatorConfigCRDResourceValidation)
} }

View File

@ -37,8 +37,11 @@ type OperatorConfigurationList struct {
// PostgresUsersConfiguration defines the system users of Postgres. // PostgresUsersConfiguration defines the system users of Postgres.
type PostgresUsersConfiguration struct { type PostgresUsersConfiguration struct {
SuperUsername string `json:"super_username,omitempty"` SuperUsername string `json:"super_username,omitempty"`
ReplicationUsername string `json:"replication_username,omitempty"` ReplicationUsername string `json:"replication_username,omitempty"`
EnablePasswordRotation bool `json:"enable_password_rotation,omitempty"`
PasswordRotationInterval uint32 `json:"password_rotation_interval,omitempty"`
PasswordRotationUserRetention uint32 `json:"password_rotation_user_retention,omitempty"`
} }
// MajorVersionUpgradeConfiguration defines how to execute major version upgrades of Postgres. // MajorVersionUpgradeConfiguration defines how to execute major version upgrades of Postgres.
@ -82,6 +85,7 @@ type KubernetesMetaConfiguration struct {
DeleteAnnotationDateKey string `json:"delete_annotation_date_key,omitempty"` DeleteAnnotationDateKey string `json:"delete_annotation_date_key,omitempty"`
DeleteAnnotationNameKey string `json:"delete_annotation_name_key,omitempty"` DeleteAnnotationNameKey string `json:"delete_annotation_name_key,omitempty"`
NodeReadinessLabel map[string]string `json:"node_readiness_label,omitempty"` NodeReadinessLabel map[string]string `json:"node_readiness_label,omitempty"`
NodeReadinessLabelMerge string `json:"node_readiness_label_merge,omitempty"`
CustomPodAnnotations map[string]string `json:"custom_pod_annotations,omitempty"` CustomPodAnnotations map[string]string `json:"custom_pod_annotations,omitempty"`
// TODO: use a proper toleration structure? // TODO: use a proper toleration structure?
PodToleration map[string]string `json:"toleration,omitempty"` PodToleration map[string]string `json:"toleration,omitempty"`
@ -217,6 +221,7 @@ type OperatorLogicalBackupConfiguration struct {
type OperatorConfigurationData struct { type OperatorConfigurationData struct {
EnableCRDRegistration *bool `json:"enable_crd_registration,omitempty"` EnableCRDRegistration *bool `json:"enable_crd_registration,omitempty"`
EnableCRDValidation *bool `json:"enable_crd_validation,omitempty"` EnableCRDValidation *bool `json:"enable_crd_validation,omitempty"`
CRDCategories []string `json:"crd_categories,omitempty"`
EnableLazySpiloUpgrade bool `json:"enable_lazy_spilo_upgrade,omitempty"` EnableLazySpiloUpgrade bool `json:"enable_lazy_spilo_upgrade,omitempty"`
EnablePgVersionEnvVar bool `json:"enable_pgversion_env_var,omitempty"` EnablePgVersionEnvVar bool `json:"enable_pgversion_env_var,omitempty"`
EnableSpiloWalPathCompat bool `json:"enable_spilo_wal_path_compat,omitempty"` EnableSpiloWalPathCompat bool `json:"enable_spilo_wal_path_compat,omitempty"`

View File

@ -53,8 +53,11 @@ type PostgresSpec struct {
// load balancers' source ranges are the same for master and replica services // load balancers' source ranges are the same for master and replica services
AllowedSourceRanges []string `json:"allowedSourceRanges"` AllowedSourceRanges []string `json:"allowedSourceRanges"`
Users map[string]UserFlags `json:"users,omitempty"`
UsersWithSecretRotation []string `json:"usersWithSecretRotation,omitempty"`
UsersWithInPlaceSecretRotation []string `json:"usersWithInPlaceSecretRotation,omitempty"`
NumberOfInstances int32 `json:"numberOfInstances"` NumberOfInstances int32 `json:"numberOfInstances"`
Users map[string]UserFlags `json:"users,omitempty"`
MaintenanceWindows []MaintenanceWindow `json:"maintenanceWindows,omitempty"` MaintenanceWindows []MaintenanceWindow `json:"maintenanceWindows,omitempty"`
Clone *CloneDescription `json:"clone,omitempty"` Clone *CloneDescription `json:"clone,omitempty"`
ClusterName string `json:"-"` ClusterName string `json:"-"`

View File

@ -377,6 +377,11 @@ func (in *OperatorConfigurationData) DeepCopyInto(out *OperatorConfigurationData
*out = new(bool) *out = new(bool)
**out = **in **out = **in
} }
if in.CRDCategories != nil {
in, out := &in.CRDCategories, &out.CRDCategories
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.ShmVolume != nil { if in.ShmVolume != nil {
in, out := &in.ShmVolume, &out.ShmVolume in, out := &in.ShmVolume, &out.ShmVolume
*out = new(bool) *out = new(bool)
@ -641,6 +646,16 @@ func (in *PostgresSpec) DeepCopyInto(out *PostgresSpec) {
(*out)[key] = outVal (*out)[key] = outVal
} }
} }
if in.UsersWithSecretRotation != nil {
in, out := &in.UsersWithSecretRotation, &out.UsersWithSecretRotation
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.UsersWithInPlaceSecretRotation != nil {
in, out := &in.UsersWithInPlaceSecretRotation, &out.UsersWithInPlaceSecretRotation
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.MaintenanceWindows != nil { if in.MaintenanceWindows != nil {
in, out := &in.MaintenanceWindows, &out.MaintenanceWindows in, out := &in.MaintenanceWindows, &out.MaintenanceWindows
*out = make([]MaintenanceWindow, len(*in)) *out = make([]MaintenanceWindow, len(*in))

View File

@ -381,7 +381,6 @@ func (c *Cluster) compareStatefulSetWith(statefulSet *appsv1.StatefulSet) *compa
reasons = append(reasons, "new statefulset's number of replicas does not match the current one") reasons = append(reasons, "new statefulset's number of replicas does not match the current one")
} }
if !reflect.DeepEqual(c.Statefulset.Annotations, statefulSet.Annotations) { if !reflect.DeepEqual(c.Statefulset.Annotations, statefulSet.Annotations) {
match = false
needsReplace = true needsReplace = true
reasons = append(reasons, "new statefulset's annotations do not match the current one") reasons = append(reasons, "new statefulset's annotations do not match the current one")
} }
@ -412,6 +411,11 @@ func (c *Cluster) compareStatefulSetWith(statefulSet *appsv1.StatefulSet) *compa
needsRollUpdate = true needsRollUpdate = true
reasons = append(reasons, "new statefulset's pod affinity does not match the current one") reasons = append(reasons, "new statefulset's pod affinity does not match the current one")
} }
if len(c.Statefulset.Spec.Template.Spec.Tolerations) != len(statefulSet.Spec.Template.Spec.Tolerations) {
needsReplace = true
needsRollUpdate = true
reasons = append(reasons, "new statefulset's pod tolerations does not match the current one")
}
// Some generated fields like creationTimestamp make it not possible to use DeepCompare on Spec.Template.ObjectMeta // Some generated fields like creationTimestamp make it not possible to use DeepCompare on Spec.Template.ObjectMeta
if !reflect.DeepEqual(c.Statefulset.Spec.Template.Labels, statefulSet.Spec.Template.Labels) { if !reflect.DeepEqual(c.Statefulset.Spec.Template.Labels, statefulSet.Spec.Template.Labels) {
@ -433,13 +437,11 @@ func (c *Cluster) compareStatefulSetWith(statefulSet *appsv1.StatefulSet) *compa
} }
if !reflect.DeepEqual(c.Statefulset.Spec.Template.Annotations, statefulSet.Spec.Template.Annotations) { if !reflect.DeepEqual(c.Statefulset.Spec.Template.Annotations, statefulSet.Spec.Template.Annotations) {
match = false
needsReplace = true needsReplace = true
needsRollUpdate = true needsRollUpdate = true
reasons = append(reasons, "new statefulset's pod template metadata annotations does not match the current one") reasons = append(reasons, "new statefulset's pod template metadata annotations does not match the current one")
} }
if !reflect.DeepEqual(c.Statefulset.Spec.Template.Spec.SecurityContext, statefulSet.Spec.Template.Spec.SecurityContext) { if !reflect.DeepEqual(c.Statefulset.Spec.Template.Spec.SecurityContext, statefulSet.Spec.Template.Spec.SecurityContext) {
match = false
needsReplace = true needsReplace = true
needsRollUpdate = true needsRollUpdate = true
reasons = append(reasons, "new statefulset's pod template security context in spec does not match the current one") reasons = append(reasons, "new statefulset's pod template security context in spec does not match the current one")
@ -475,7 +477,6 @@ func (c *Cluster) compareStatefulSetWith(statefulSet *appsv1.StatefulSet) *compa
// we assume any change in priority happens by rolling out a new priority class // we assume any change in priority happens by rolling out a new priority class
// changing the priority value in an existing class is not supproted // changing the priority value in an existing class is not supproted
if c.Statefulset.Spec.Template.Spec.PriorityClassName != statefulSet.Spec.Template.Spec.PriorityClassName { if c.Statefulset.Spec.Template.Spec.PriorityClassName != statefulSet.Spec.Template.Spec.PriorityClassName {
match = false
needsReplace = true needsReplace = true
needsRollUpdate = true needsRollUpdate = true
reasons = append(reasons, "new statefulset's pod priority class in spec does not match the current one") reasons = append(reasons, "new statefulset's pod priority class in spec does not match the current one")
@ -716,13 +717,18 @@ func (c *Cluster) Update(oldSpec, newSpec *acidv1.Postgresql) error {
} }
} }
// connection pooler needs one system user created, which is done in // check if users need to be synced
// initUsers. Check if it needs to be called.
sameUsers := reflect.DeepEqual(oldSpec.Spec.Users, newSpec.Spec.Users) && sameUsers := reflect.DeepEqual(oldSpec.Spec.Users, newSpec.Spec.Users) &&
reflect.DeepEqual(oldSpec.Spec.PreparedDatabases, newSpec.Spec.PreparedDatabases) reflect.DeepEqual(oldSpec.Spec.PreparedDatabases, newSpec.Spec.PreparedDatabases)
sameRotatedUsers := reflect.DeepEqual(oldSpec.Spec.UsersWithSecretRotation, newSpec.Spec.UsersWithSecretRotation) &&
reflect.DeepEqual(oldSpec.Spec.UsersWithInPlaceSecretRotation, newSpec.Spec.UsersWithInPlaceSecretRotation)
// connection pooler needs one system user created, which is done in
// initUsers. Check if it needs to be called.
needConnectionPooler := needMasterConnectionPoolerWorker(&newSpec.Spec) || needConnectionPooler := needMasterConnectionPoolerWorker(&newSpec.Spec) ||
needReplicaConnectionPoolerWorker(&newSpec.Spec) needReplicaConnectionPoolerWorker(&newSpec.Spec)
if !sameUsers || needConnectionPooler {
if !sameUsers || !sameRotatedUsers || needConnectionPooler {
c.logger.Debugf("initialize users") c.logger.Debugf("initialize users")
if err := c.initUsers(); err != nil { if err := c.initUsers(); err != nil {
c.logger.Errorf("could not init users: %v", err) c.logger.Errorf("could not init users: %v", err)
@ -1017,6 +1023,7 @@ func (c *Cluster) initSystemUsers() {
Origin: spec.RoleOriginSystem, Origin: spec.RoleOriginSystem,
Name: c.OpConfig.ReplicationUsername, Name: c.OpConfig.ReplicationUsername,
Namespace: c.Namespace, Namespace: c.Namespace,
Flags: []string{constants.RoleFlagLogin},
Password: util.RandomPassword(constants.PasswordLength), Password: util.RandomPassword(constants.PasswordLength),
} }
@ -1146,7 +1153,6 @@ func (c *Cluster) initPreparedDatabaseRoles() error {
func (c *Cluster) initDefaultRoles(defaultRoles map[string]string, admin, prefix, searchPath, secretNamespace string) error { func (c *Cluster) initDefaultRoles(defaultRoles map[string]string, admin, prefix, searchPath, secretNamespace string) error {
for defaultRole, inherits := range defaultRoles { for defaultRole, inherits := range defaultRoles {
namespace := c.Namespace namespace := c.Namespace
//if namespaced secrets are allowed //if namespaced secrets are allowed
if secretNamespace != "" { if secretNamespace != "" {
@ -1169,8 +1175,10 @@ func (c *Cluster) initDefaultRoles(defaultRoles map[string]string, admin, prefix
} }
adminRole := "" adminRole := ""
isOwner := false
if strings.Contains(defaultRole, constants.OwnerRoleNameSuffix) { if strings.Contains(defaultRole, constants.OwnerRoleNameSuffix) {
adminRole = admin adminRole = admin
isOwner = true
} else { } else {
adminRole = prefix + constants.OwnerRoleNameSuffix adminRole = prefix + constants.OwnerRoleNameSuffix
} }
@ -1184,6 +1192,7 @@ func (c *Cluster) initDefaultRoles(defaultRoles map[string]string, admin, prefix
MemberOf: memberOf, MemberOf: memberOf,
Parameters: map[string]string{"search_path": searchPath}, Parameters: map[string]string{"search_path": searchPath},
AdminRole: adminRole, AdminRole: adminRole,
IsDbOwner: isOwner,
} }
if currentRole, present := c.pgUsers[roleName]; present { if currentRole, present := c.pgUsers[roleName]; present {
c.pgUsers[roleName] = c.resolveNameConflict(&currentRole, &newRole) c.pgUsers[roleName] = c.resolveNameConflict(&currentRole, &newRole)
@ -1205,6 +1214,14 @@ func (c *Cluster) initRobotUsers() error {
} }
namespace := c.Namespace namespace := c.Namespace
// check if role is specified as database owner
isOwner := false
for _, owner := range c.Spec.Databases {
if username == owner {
isOwner = true
}
}
//if namespaced secrets are allowed //if namespaced secrets are allowed
if c.Config.OpConfig.EnableCrossNamespaceSecret { if c.Config.OpConfig.EnableCrossNamespaceSecret {
if strings.Contains(username, ".") { if strings.Contains(username, ".") {
@ -1229,6 +1246,7 @@ func (c *Cluster) initRobotUsers() error {
Password: util.RandomPassword(constants.PasswordLength), Password: util.RandomPassword(constants.PasswordLength),
Flags: flags, Flags: flags,
AdminRole: adminRole, AdminRole: adminRole,
IsDbOwner: isOwner,
} }
if currentRole, present := c.pgUsers[username]; present { if currentRole, present := c.pgUsers[username]; present {
c.pgUsers[username] = c.resolveNameConflict(&currentRole, &newRole) c.pgUsers[username] = c.resolveNameConflict(&currentRole, &newRole)

View File

@ -309,7 +309,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
}, },
} }
nodeAffinity := nodeAffinity(c.OpConfig.NodeReadinessLabel, spec.NodeAffinity) nodeAffinity := c.nodeAffinity(c.OpConfig.NodeReadinessLabel, spec.NodeAffinity)
if c.OpConfig.EnablePodAntiAffinity { if c.OpConfig.EnablePodAntiAffinity {
labelsSet := labels.Set(c.connectionPoolerLabels(role, false).MatchLabels) labelsSet := labels.Set(c.connectionPoolerLabels(role, false).MatchLabels)
podTemplate.Spec.Affinity = generatePodAffinity(labelsSet, c.OpConfig.PodAntiAffinityTopologyKey, nodeAffinity) podTemplate.Spec.Affinity = generatePodAffinity(labelsSet, c.OpConfig.PodAntiAffinityTopologyKey, nodeAffinity)

View File

@ -14,18 +14,25 @@ import (
"github.com/zalando/postgres-operator/pkg/spec" "github.com/zalando/postgres-operator/pkg/spec"
"github.com/zalando/postgres-operator/pkg/util/constants" "github.com/zalando/postgres-operator/pkg/util/constants"
"github.com/zalando/postgres-operator/pkg/util/retryutil" "github.com/zalando/postgres-operator/pkg/util/retryutil"
"github.com/zalando/postgres-operator/pkg/util/users"
) )
const ( const (
getUserSQL = `SELECT a.rolname, COALESCE(a.rolpassword, ''), a.rolsuper, a.rolinherit, getUserSQL = `SELECT a.rolname, COALESCE(a.rolpassword, ''), a.rolsuper, a.rolinherit,
a.rolcreaterole, a.rolcreatedb, a.rolcanlogin, s.setconfig, a.rolcreaterole, a.rolcreatedb, a.rolcanlogin, s.setconfig,
ARRAY(SELECT b.rolname ARRAY(SELECT b.rolname
FROM pg_catalog.pg_auth_members m FROM pg_catalog.pg_auth_members m
JOIN pg_catalog.pg_authid b ON (m.roleid = b.oid) JOIN pg_catalog.pg_authid b ON (m.roleid = b.oid)
WHERE m.member = a.oid) as memberof WHERE m.member = a.oid) as memberof
FROM pg_catalog.pg_authid a LEFT JOIN pg_db_role_setting s ON (a.oid = s.setrole AND s.setdatabase = 0::oid) FROM pg_catalog.pg_authid a LEFT JOIN pg_db_role_setting s ON (a.oid = s.setrole AND s.setdatabase = 0::oid)
WHERE a.rolname = ANY($1) WHERE a.rolname = ANY($1)
ORDER BY 1;` ORDER BY 1;`
getUsersForRetention = `SELECT r.rolname, right(r.rolname, 6) AS roldatesuffix
FROM pg_roles r
JOIN unnest($1::text[]) AS u(name) ON r.rolname LIKE u.name || '%'
AND right(r.rolname, 6) ~ '^[0-9\.]+$'
ORDER BY 1;`
getDatabasesSQL = `SELECT datname, pg_get_userbyid(datdba) AS owner FROM pg_database;` getDatabasesSQL = `SELECT datname, pg_get_userbyid(datdba) AS owner FROM pg_database;`
getSchemasSQL = `SELECT n.nspname AS dbschema FROM pg_catalog.pg_namespace n getSchemasSQL = `SELECT n.nspname AS dbschema FROM pg_catalog.pg_namespace n
@ -234,6 +241,65 @@ func (c *Cluster) readPgUsersFromDatabase(userNames []string) (users spec.PgUser
return users, nil return users, nil
} }
func findUsersFromRotation(rotatedUsers []string, db *sql.DB) (map[string]string, error) {
extraUsers := make(map[string]string, 0)
rows, err := db.Query(getUsersForRetention, pq.Array(rotatedUsers))
if err != nil {
return nil, fmt.Errorf("query failed: %v", err)
}
defer func() {
if err2 := rows.Close(); err2 != nil {
err = fmt.Errorf("error when closing query cursor: %v", err2)
}
}()
for rows.Next() {
var (
rolname, roldatesuffix string
)
err := rows.Scan(&rolname, &roldatesuffix)
if err != nil {
return nil, fmt.Errorf("error when processing rows of deprecated users: %v", err)
}
extraUsers[rolname] = roldatesuffix
}
return extraUsers, nil
}
func (c *Cluster) cleanupRotatedUsers(rotatedUsers []string, db *sql.DB) error {
c.setProcessName("checking for rotated users to remove from the database due to configured retention")
extraUsers, err := findUsersFromRotation(rotatedUsers, db)
if err != nil {
return fmt.Errorf("error when querying for deprecated users from password rotation: %v", err)
}
// make sure user retention policy aligns with rotation interval
retenionDays := c.OpConfig.PasswordRotationUserRetention
if retenionDays < 2*c.OpConfig.PasswordRotationInterval {
retenionDays = 2 * c.OpConfig.PasswordRotationInterval
c.logger.Warnf("user retention days too few compared to rotation interval %d - setting it to %d", c.OpConfig.PasswordRotationInterval, retenionDays)
}
retentionDate := time.Now().AddDate(0, 0, int(retenionDays)*-1)
for rotatedUser, dateSuffix := range extraUsers {
userCreationDate, err := time.Parse("060102", dateSuffix)
if err != nil {
c.logger.Errorf("could not parse creation date suffix of user %q: %v", rotatedUser, err)
continue
}
if retentionDate.After(userCreationDate) {
c.logger.Infof("dropping user %q due to configured days in password_rotation_user_retention", rotatedUser)
if err = users.DropPgUser(rotatedUser, db); err != nil {
c.logger.Errorf("could not drop role %q: %v", rotatedUser, err)
continue
}
}
}
return nil
}
// getDatabases returns the map of current databases with owners // getDatabases returns the map of current databases with owners
// The caller is responsible for opening and closing the database connection // The caller is responsible for opening and closing the database connection
func (c *Cluster) getDatabases() (dbs map[string]string, err error) { func (c *Cluster) getDatabases() (dbs map[string]string, err error) {

View File

@ -327,7 +327,7 @@ func generateCapabilities(capabilities []string) *v1.Capabilities {
return nil return nil
} }
func nodeAffinity(nodeReadinessLabel map[string]string, nodeAffinity *v1.NodeAffinity) *v1.Affinity { func (c *Cluster) nodeAffinity(nodeReadinessLabel map[string]string, nodeAffinity *v1.NodeAffinity) *v1.Affinity {
if len(nodeReadinessLabel) == 0 && nodeAffinity == nil { if len(nodeReadinessLabel) == 0 && nodeAffinity == nil {
return nil return nil
} }
@ -352,8 +352,18 @@ func nodeAffinity(nodeReadinessLabel map[string]string, nodeAffinity *v1.NodeAff
}, },
} }
} else { } else {
nodeAffinityCopy.RequiredDuringSchedulingIgnoredDuringExecution = &v1.NodeSelector{ if c.OpConfig.NodeReadinessLabelMerge == "OR" {
NodeSelectorTerms: append(nodeAffinityCopy.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms, nodeReadinessSelectorTerm), manifestTerms := nodeAffinityCopy.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms
manifestTerms = append(manifestTerms, nodeReadinessSelectorTerm)
nodeAffinityCopy.RequiredDuringSchedulingIgnoredDuringExecution = &v1.NodeSelector{
NodeSelectorTerms: manifestTerms,
}
} else if c.OpConfig.NodeReadinessLabelMerge == "AND" {
for i, nodeSelectorTerm := range nodeAffinityCopy.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms {
manifestExpressions := nodeSelectorTerm.MatchExpressions
manifestExpressions = append(manifestExpressions, matchExpressions...)
nodeAffinityCopy.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[i] = v1.NodeSelectorTerm{MatchExpressions: manifestExpressions}
}
} }
} }
} }
@ -1260,7 +1270,7 @@ func (c *Cluster) generateStatefulSet(spec *acidv1.PostgresSpec) (*appsv1.Statef
effectiveRunAsUser, effectiveRunAsUser,
effectiveRunAsGroup, effectiveRunAsGroup,
effectiveFSGroup, effectiveFSGroup,
nodeAffinity(c.OpConfig.NodeReadinessLabel, spec.NodeAffinity), c.nodeAffinity(c.OpConfig.NodeReadinessLabel, spec.NodeAffinity),
spec.SchedulerName, spec.SchedulerName,
int64(c.OpConfig.PodTerminateGracePeriod.Seconds()), int64(c.OpConfig.PodTerminateGracePeriod.Seconds()),
c.OpConfig.PodServiceAccountName, c.OpConfig.PodServiceAccountName,
@ -2010,7 +2020,7 @@ func (c *Cluster) generateLogicalBackupJob() (*batchv1beta1.CronJob, error) {
nil, nil,
nil, nil,
nil, nil,
nodeAffinity(c.OpConfig.NodeReadinessLabel, nil), c.nodeAffinity(c.OpConfig.NodeReadinessLabel, nil),
nil, nil,
int64(c.OpConfig.PodTerminateGracePeriod.Seconds()), int64(c.OpConfig.PodTerminateGracePeriod.Seconds()),
c.OpConfig.PodServiceAccountName, c.OpConfig.PodServiceAccountName,

View File

@ -620,60 +620,180 @@ func (c *Cluster) checkAndSetGlobalPostgreSQLConfiguration(pod *v1.Pod, effectiv
return requiresMasterRestart, nil return requiresMasterRestart, nil
} }
func (c *Cluster) getNextRotationDate(currentDate time.Time) (time.Time, string) {
nextRotationDate := currentDate.AddDate(0, 0, int(c.OpConfig.PasswordRotationInterval))
return nextRotationDate, nextRotationDate.Format("2006-01-02 15:04:05")
}
func (c *Cluster) syncSecrets() error { func (c *Cluster) syncSecrets() error {
var (
err error
secret *v1.Secret
)
c.logger.Info("syncing secrets") c.logger.Info("syncing secrets")
c.setProcessName("syncing secrets") c.setProcessName("syncing secrets")
secrets := c.generateUserSecrets() generatedSecrets := c.generateUserSecrets()
rotationUsers := make(spec.PgUserMap)
retentionUsers := make([]string, 0)
currentTime := time.Now()
for secretUsername, secretSpec := range secrets { for secretUsername, generatedSecret := range generatedSecrets {
if secret, err = c.KubeClient.Secrets(secretSpec.Namespace).Create(context.TODO(), secretSpec, metav1.CreateOptions{}); err == nil { secret, err := c.KubeClient.Secrets(generatedSecret.Namespace).Create(context.TODO(), generatedSecret, metav1.CreateOptions{})
if err == nil {
c.Secrets[secret.UID] = secret c.Secrets[secret.UID] = secret
c.logger.Debugf("created new secret %s, namespace: %s, uid: %s", util.NameFromMeta(secret.ObjectMeta), secretSpec.Namespace, secret.UID) c.logger.Debugf("created new secret %s, namespace: %s, uid: %s", util.NameFromMeta(secret.ObjectMeta), generatedSecret.Namespace, secret.UID)
continue continue
} }
if k8sutil.ResourceAlreadyExists(err) { if k8sutil.ResourceAlreadyExists(err) {
var userMap map[string]spec.PgUser if err = c.updateSecret(secretUsername, generatedSecret, &rotationUsers, &retentionUsers, currentTime); err != nil {
if secret, err = c.KubeClient.Secrets(secretSpec.Namespace).Get(context.TODO(), secretSpec.Name, metav1.GetOptions{}); err != nil { c.logger.Warningf("syncing secret %s failed: %v", util.NameFromMeta(secret.ObjectMeta), err)
return fmt.Errorf("could not get current secret: %v", err)
}
if secretUsername != string(secret.Data["username"]) {
c.logger.Errorf("secret %s does not contain the role %s", secretSpec.Name, secretUsername)
continue
}
c.Secrets[secret.UID] = secret
c.logger.Debugf("secret %s already exists, fetching its password", util.NameFromMeta(secret.ObjectMeta))
if secretUsername == c.systemUsers[constants.SuperuserKeyName].Name {
secretUsername = constants.SuperuserKeyName
userMap = c.systemUsers
} else if secretUsername == c.systemUsers[constants.ReplicationUserKeyName].Name {
secretUsername = constants.ReplicationUserKeyName
userMap = c.systemUsers
} else {
userMap = c.pgUsers
}
pwdUser := userMap[secretUsername]
// if this secret belongs to the infrastructure role and the password has changed - replace it in the secret
if pwdUser.Password != string(secret.Data["password"]) &&
pwdUser.Origin == spec.RoleOriginInfrastructure {
c.logger.Debugf("updating the secret %s from the infrastructure roles", secretSpec.Name)
if _, err = c.KubeClient.Secrets(secretSpec.Namespace).Update(context.TODO(), secretSpec, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("could not update infrastructure role secret for role %q: %v", secretUsername, err)
}
} else {
// for non-infrastructure role - update the role with the password from the secret
pwdUser.Password = string(secret.Data["password"])
userMap[secretUsername] = pwdUser
} }
} else { } else {
return fmt.Errorf("could not create secret for user %s: in namespace %s: %v", secretUsername, secretSpec.Namespace, err) return fmt.Errorf("could not create secret for user %s: in namespace %s: %v", secretUsername, generatedSecret.Namespace, err)
} }
} }
// add new user with date suffix and use it in the secret of the original user
if len(rotationUsers) > 0 {
err := c.initDbConn()
if err != nil {
return fmt.Errorf("could not init db connection: %v", err)
}
pgSyncRequests := c.userSyncStrategy.ProduceSyncRequests(spec.PgUserMap{}, rotationUsers)
if err = c.userSyncStrategy.ExecuteSyncRequests(pgSyncRequests, c.pgDb); err != nil {
return fmt.Errorf("error creating database roles for password rotation: %v", err)
}
if err := c.closeDbConn(); err != nil {
c.logger.Errorf("could not close database connection after creating users for password rotation: %v", err)
}
}
// remove rotation users that exceed the retention interval
if len(retentionUsers) > 0 {
err := c.initDbConn()
if err != nil {
return fmt.Errorf("could not init db connection: %v", err)
}
if err = c.cleanupRotatedUsers(retentionUsers, c.pgDb); err != nil {
return fmt.Errorf("error removing users exceeding configured retention interval: %v", err)
}
if err := c.closeDbConn(); err != nil {
c.logger.Errorf("could not close database connection after removing users exceeding configured retention interval: %v", err)
}
}
return nil
}
func (c *Cluster) updateSecret(
secretUsername string,
generatedSecret *v1.Secret,
rotationUsers *spec.PgUserMap,
retentionUsers *[]string,
currentTime time.Time) error {
var (
secret *v1.Secret
err error
updateSecret bool
updateSecretMsg string
nextRotationDate time.Time
nextRotationDateStr string
)
// get the secret first
if secret, err = c.KubeClient.Secrets(generatedSecret.Namespace).Get(context.TODO(), generatedSecret.Name, metav1.GetOptions{}); err != nil {
return fmt.Errorf("could not get current secret: %v", err)
}
c.Secrets[secret.UID] = secret
// fetch user map to update later
var userMap map[string]spec.PgUser
var userKey string
if secretUsername == c.systemUsers[constants.SuperuserKeyName].Name {
userKey = constants.SuperuserKeyName
userMap = c.systemUsers
} else if secretUsername == c.systemUsers[constants.ReplicationUserKeyName].Name {
userKey = constants.ReplicationUserKeyName
userMap = c.systemUsers
} else {
userKey = secretUsername
userMap = c.pgUsers
}
pwdUser := userMap[userKey]
secretName := util.NameFromMeta(secret.ObjectMeta)
// if password rotation is enabled update password and username if rotation interval has been passed
if (c.OpConfig.EnablePasswordRotation && !pwdUser.IsDbOwner &&
pwdUser.Origin != spec.RoleOriginInfrastructure && pwdUser.Origin != spec.RoleOriginSystem) ||
util.SliceContains(c.Spec.UsersWithSecretRotation, secretUsername) ||
util.SliceContains(c.Spec.UsersWithInPlaceSecretRotation, secretUsername) {
// initialize password rotation setting first rotation date
nextRotationDateStr = string(secret.Data["nextRotation"])
if nextRotationDate, err = time.ParseInLocation("2006-01-02 15:04:05", nextRotationDateStr, time.Local); err != nil {
nextRotationDate, nextRotationDateStr = c.getNextRotationDate(currentTime)
secret.Data["nextRotation"] = []byte(nextRotationDateStr)
updateSecret = true
updateSecretMsg = fmt.Sprintf("rotation date not found in secret %q. Setting it to %s", secretName, nextRotationDateStr)
}
// check if next rotation can happen sooner
// if rotation interval has been decreased
currentRotationDate, _ := c.getNextRotationDate(currentTime)
if nextRotationDate.After(currentRotationDate) {
nextRotationDate = currentRotationDate
}
// update password and next rotation date if configured interval has passed
if currentTime.After(nextRotationDate) {
// create rotation user if role is not listed for in-place password update
if !util.SliceContains(c.Spec.UsersWithInPlaceSecretRotation, secretUsername) {
rotationUser := pwdUser
newRotationUsername := secretUsername + currentTime.Format("060102")
rotationUser.Name = newRotationUsername
rotationUser.MemberOf = []string{secretUsername}
(*rotationUsers)[newRotationUsername] = rotationUser
secret.Data["username"] = []byte(newRotationUsername)
// whenever there is a rotation, check if old rotation users can be deleted
*retentionUsers = append(*retentionUsers, secretUsername)
}
secret.Data["password"] = []byte(util.RandomPassword(constants.PasswordLength))
_, nextRotationDateStr = c.getNextRotationDate(nextRotationDate)
secret.Data["nextRotation"] = []byte(nextRotationDateStr)
updateSecret = true
updateSecretMsg = fmt.Sprintf("updating secret %q due to password rotation - next rotation date: %s", secretName, nextRotationDateStr)
}
} else {
// username might not match if password rotation has been disabled again
if secretUsername != string(secret.Data["username"]) {
*retentionUsers = append(*retentionUsers, secretUsername)
secret.Data["username"] = []byte(secretUsername)
secret.Data["password"] = []byte(util.RandomPassword(constants.PasswordLength))
secret.Data["nextRotation"] = []byte{}
updateSecret = true
updateSecretMsg = fmt.Sprintf("secret %s does not contain the role %s - updating username and resetting password", secretName, secretUsername)
}
}
// if this secret belongs to the infrastructure role and the password has changed - replace it in the secret
if pwdUser.Password != string(secret.Data["password"]) && pwdUser.Origin == spec.RoleOriginInfrastructure {
secret = generatedSecret
updateSecret = true
updateSecretMsg = fmt.Sprintf("updating the secret %s from the infrastructure roles", secretName)
} else {
// for non-infrastructure role - update the role with the password from the secret
pwdUser.Password = string(secret.Data["password"])
userMap[userKey] = pwdUser
}
if updateSecret {
c.logger.Debugln(updateSecretMsg)
if _, err = c.KubeClient.Secrets(secret.Namespace).Update(context.TODO(), secret, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("could not update secret %q: %v", secretName, err)
}
c.Secrets[secret.UID] = secret
}
return nil return nil
} }

View File

@ -19,6 +19,7 @@ import (
"github.com/zalando/postgres-operator/mocks" "github.com/zalando/postgres-operator/mocks"
acidv1 "github.com/zalando/postgres-operator/pkg/apis/acid.zalan.do/v1" acidv1 "github.com/zalando/postgres-operator/pkg/apis/acid.zalan.do/v1"
fakeacidv1 "github.com/zalando/postgres-operator/pkg/generated/clientset/versioned/fake" fakeacidv1 "github.com/zalando/postgres-operator/pkg/generated/clientset/versioned/fake"
"github.com/zalando/postgres-operator/pkg/spec"
"github.com/zalando/postgres-operator/pkg/util/config" "github.com/zalando/postgres-operator/pkg/util/config"
"github.com/zalando/postgres-operator/pkg/util/k8sutil" "github.com/zalando/postgres-operator/pkg/util/k8sutil"
"github.com/zalando/postgres-operator/pkg/util/patroni" "github.com/zalando/postgres-operator/pkg/util/patroni"
@ -26,6 +27,8 @@ import (
) )
var patroniLogger = logrus.New().WithField("test", "patroni") var patroniLogger = logrus.New().WithField("test", "patroni")
var acidClientSet = fakeacidv1.NewSimpleClientset()
var clientSet = fake.NewSimpleClientset()
func newMockPod(ip string) *v1.Pod { func newMockPod(ip string) *v1.Pod {
return &v1.Pod{ return &v1.Pod{
@ -36,9 +39,6 @@ func newMockPod(ip string) *v1.Pod {
} }
func newFakeK8sSyncClient() (k8sutil.KubernetesClient, *fake.Clientset) { func newFakeK8sSyncClient() (k8sutil.KubernetesClient, *fake.Clientset) {
acidClientSet := fakeacidv1.NewSimpleClientset()
clientSet := fake.NewSimpleClientset()
return k8sutil.KubernetesClient{ return k8sutil.KubernetesClient{
PodsGetter: clientSet.CoreV1(), PodsGetter: clientSet.CoreV1(),
PostgresqlsGetter: acidClientSet.AcidV1(), PostgresqlsGetter: acidClientSet.AcidV1(),
@ -46,6 +46,12 @@ func newFakeK8sSyncClient() (k8sutil.KubernetesClient, *fake.Clientset) {
}, clientSet }, clientSet
} }
func newFakeK8sSyncSecretsClient() (k8sutil.KubernetesClient, *fake.Clientset) {
return k8sutil.KubernetesClient{
SecretsGetter: clientSet.CoreV1(),
}, clientSet
}
func TestSyncStatefulSetsAnnotations(t *testing.T) { func TestSyncStatefulSetsAnnotations(t *testing.T) {
testName := "test syncing statefulsets annotations" testName := "test syncing statefulsets annotations"
client, _ := newFakeK8sSyncClient() client, _ := newFakeK8sSyncClient()
@ -257,3 +263,72 @@ func TestCheckAndSetGlobalPostgreSQLConfiguration(t *testing.T) {
} }
} }
} }
func TestUpdateSecret(t *testing.T) {
testName := "test syncing secrets"
client, _ := newFakeK8sSyncSecretsClient()
clusterName := "acid-test-cluster"
namespace := "default"
username := "foo"
secretTemplate := config.StringTemplate("{username}.{cluster}.credentials")
rotationUsers := make(spec.PgUserMap)
retentionUsers := make([]string, 0)
yesterday := time.Now().AddDate(0, 0, -1)
// new cluster with pvc storage resize mode and configured labels
var cluster = New(
Config{
OpConfig: config.Config{
Auth: config.Auth{
SecretNameTemplate: secretTemplate,
EnablePasswordRotation: true,
PasswordRotationInterval: 1,
PasswordRotationUserRetention: 3,
},
Resources: config.Resources{
ClusterLabels: map[string]string{"application": "spilo"},
ClusterNameLabel: "cluster-name",
},
},
}, client, acidv1.Postgresql{}, logger, eventRecorder)
cluster.Name = clusterName
cluster.Namespace = namespace
cluster.pgUsers = map[string]spec.PgUser{}
cluster.Spec.Users = map[string]acidv1.UserFlags{username: {}}
cluster.initRobotUsers()
// create a secret for user foo
cluster.syncSecrets()
secret, err := cluster.KubeClient.Secrets(namespace).Get(context.TODO(), secretTemplate.Format("username", username, "cluster", clusterName), metav1.GetOptions{})
assert.NoError(t, err)
generatedSecret := cluster.Secrets[secret.UID]
// now update the secret setting next rotation date (yesterday + interval)
cluster.updateSecret(username, generatedSecret, &rotationUsers, &retentionUsers, yesterday)
updatedSecret, err := cluster.KubeClient.Secrets(namespace).Get(context.TODO(), secretTemplate.Format("username", username, "cluster", clusterName), metav1.GetOptions{})
assert.NoError(t, err)
nextRotation := string(updatedSecret.Data["nextRotation"])
_, nextRotationDate := cluster.getNextRotationDate(yesterday)
if nextRotation != nextRotationDate {
t.Errorf("%s: updated secret does not contain correct rotation date: expected %s, got %s", testName, nextRotationDate, nextRotation)
}
// update secret again but use current time to trigger rotation
cluster.updateSecret(username, generatedSecret, &rotationUsers, &retentionUsers, time.Now())
updatedSecret, err = cluster.KubeClient.Secrets(namespace).Get(context.TODO(), secretTemplate.Format("username", username, "cluster", clusterName), metav1.GetOptions{})
assert.NoError(t, err)
if len(rotationUsers) != 1 && len(retentionUsers) != 1 {
t.Errorf("%s: unexpected number of users to rotate - expected only foo, found %d", testName, len(rotationUsers))
}
secretUsername := string(updatedSecret.Data["username"])
rotatedUsername := username + time.Now().Format("060102")
if secretUsername != rotatedUsername {
t.Errorf("%s: updated secret does not contain correct username: expected %s, got %s", testName, rotatedUsername, secretUsername)
}
}

View File

@ -310,7 +310,7 @@ func (c *Controller) initController() {
if configObjectName := os.Getenv("POSTGRES_OPERATOR_CONFIGURATION_OBJECT"); configObjectName != "" { if configObjectName := os.Getenv("POSTGRES_OPERATOR_CONFIGURATION_OBJECT"); configObjectName != "" {
if c.opConfig.EnableCRDRegistration != nil && *c.opConfig.EnableCRDRegistration { if c.opConfig.EnableCRDRegistration != nil && *c.opConfig.EnableCRDRegistration {
if err := c.createConfigurationCRD(c.opConfig.EnableCRDValidation); err != nil { if err := c.createConfigurationCRD(); err != nil {
c.logger.Fatalf("could not register Operator Configuration CustomResourceDefinition: %v", err) c.logger.Fatalf("could not register Operator Configuration CustomResourceDefinition: %v", err)
} }
} }
@ -328,7 +328,7 @@ func (c *Controller) initController() {
c.modifyConfigFromEnvironment() c.modifyConfigFromEnvironment()
if c.opConfig.EnableCRDRegistration != nil && *c.opConfig.EnableCRDRegistration { if c.opConfig.EnableCRDRegistration != nil && *c.opConfig.EnableCRDRegistration {
if err := c.createPostgresCRD(c.opConfig.EnableCRDValidation); err != nil { if err := c.createPostgresCRD(); err != nil {
c.logger.Fatalf("could not register Postgres CustomResourceDefinition: %v", err) c.logger.Fatalf("could not register Postgres CustomResourceDefinition: %v", err)
} }
} }

View File

@ -35,6 +35,7 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur
// general config // general config
result.EnableCRDRegistration = util.CoalesceBool(fromCRD.EnableCRDRegistration, util.True()) result.EnableCRDRegistration = util.CoalesceBool(fromCRD.EnableCRDRegistration, util.True())
result.EnableCRDValidation = util.CoalesceBool(fromCRD.EnableCRDValidation, util.True()) result.EnableCRDValidation = util.CoalesceBool(fromCRD.EnableCRDValidation, util.True())
result.CRDCategories = util.CoalesceStrArr(fromCRD.CRDCategories, []string{"all"})
result.EnableLazySpiloUpgrade = fromCRD.EnableLazySpiloUpgrade result.EnableLazySpiloUpgrade = fromCRD.EnableLazySpiloUpgrade
result.EnablePgVersionEnvVar = fromCRD.EnablePgVersionEnvVar result.EnablePgVersionEnvVar = fromCRD.EnablePgVersionEnvVar
result.EnableSpiloWalPathCompat = fromCRD.EnableSpiloWalPathCompat result.EnableSpiloWalPathCompat = fromCRD.EnableSpiloWalPathCompat
@ -54,6 +55,9 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur
// user config // user config
result.SuperUsername = util.Coalesce(fromCRD.PostgresUsersConfiguration.SuperUsername, "postgres") result.SuperUsername = util.Coalesce(fromCRD.PostgresUsersConfiguration.SuperUsername, "postgres")
result.ReplicationUsername = util.Coalesce(fromCRD.PostgresUsersConfiguration.ReplicationUsername, "standby") result.ReplicationUsername = util.Coalesce(fromCRD.PostgresUsersConfiguration.ReplicationUsername, "standby")
result.EnablePasswordRotation = fromCRD.PostgresUsersConfiguration.EnablePasswordRotation
result.PasswordRotationInterval = util.CoalesceUInt32(fromCRD.PostgresUsersConfiguration.PasswordRotationInterval, 90)
result.PasswordRotationUserRetention = util.CoalesceUInt32(fromCRD.PostgresUsersConfiguration.DeepCopy().PasswordRotationUserRetention, 180)
// major version upgrade config // major version upgrade config
result.MajorVersionUpgradeMode = util.Coalesce(fromCRD.MajorVersionUpgrade.MajorVersionUpgradeMode, "off") result.MajorVersionUpgradeMode = util.Coalesce(fromCRD.MajorVersionUpgrade.MajorVersionUpgradeMode, "off")
@ -110,6 +114,7 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur
result.DeleteAnnotationDateKey = fromCRD.Kubernetes.DeleteAnnotationDateKey result.DeleteAnnotationDateKey = fromCRD.Kubernetes.DeleteAnnotationDateKey
result.DeleteAnnotationNameKey = fromCRD.Kubernetes.DeleteAnnotationNameKey result.DeleteAnnotationNameKey = fromCRD.Kubernetes.DeleteAnnotationNameKey
result.NodeReadinessLabel = fromCRD.Kubernetes.NodeReadinessLabel result.NodeReadinessLabel = fromCRD.Kubernetes.NodeReadinessLabel
result.NodeReadinessLabelMerge = fromCRD.Kubernetes.NodeReadinessLabelMerge
result.PodPriorityClassName = fromCRD.Kubernetes.PodPriorityClassName result.PodPriorityClassName = fromCRD.Kubernetes.PodPriorityClassName
result.PodManagementPolicy = util.Coalesce(fromCRD.Kubernetes.PodManagementPolicy, "ordered_ready") result.PodManagementPolicy = util.Coalesce(fromCRD.Kubernetes.PodManagementPolicy, "ordered_ready")
result.MasterPodMoveTimeout = util.CoalesceDuration(time.Duration(fromCRD.Kubernetes.MasterPodMoveTimeout), "10m") result.MasterPodMoveTimeout = util.CoalesceDuration(time.Duration(fromCRD.Kubernetes.MasterPodMoveTimeout), "10m")

View File

@ -53,28 +53,35 @@ func (c *Controller) clusterWorkerID(clusterName spec.NamespacedName) uint32 {
return c.clusterWorkers[clusterName] return c.clusterWorkers[clusterName]
} }
func (c *Controller) createOperatorCRD(crd *apiextv1.CustomResourceDefinition) error { func (c *Controller) createOperatorCRD(desiredCrd *apiextv1.CustomResourceDefinition) error {
if _, err := c.KubeClient.CustomResourceDefinitions().Create(context.TODO(), crd, metav1.CreateOptions{}); err != nil { crd, err := c.KubeClient.CustomResourceDefinitions().Get(context.TODO(), desiredCrd.Name, metav1.GetOptions{})
if k8sutil.ResourceAlreadyExists(err) { if k8sutil.ResourceNotFound(err) {
c.logger.Infof("customResourceDefinition %q is already registered and will only be updated", crd.Name) if _, err := c.KubeClient.CustomResourceDefinitions().Create(context.TODO(), desiredCrd, metav1.CreateOptions{}); err != nil {
return fmt.Errorf("could not create customResourceDefinition %q: %v", desiredCrd.Name, err)
patch, err := json.Marshal(crd) }
if err != nil { }
return fmt.Errorf("could not marshal new customResourceDefintion: %v", err) if err != nil {
} c.logger.Errorf("could not get customResourceDefinition %q: %v", desiredCrd.Name, err)
if _, err := c.KubeClient.CustomResourceDefinitions().Patch( }
context.TODO(), crd.Name, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil { if crd != nil {
return fmt.Errorf("could not update customResourceDefinition: %v", err) c.logger.Infof("customResourceDefinition %q is already registered and will only be updated", crd.Name)
} // copy annotations and labels from existing CRD since we do not define them
} else { desiredCrd.Annotations = crd.Annotations
c.logger.Errorf("could not create customResourceDefinition %q: %v", crd.Name, err) desiredCrd.Labels = crd.Labels
patch, err := json.Marshal(desiredCrd)
if err != nil {
return fmt.Errorf("could not marshal new customResourceDefintion %q: %v", desiredCrd.Name, err)
}
if _, err := c.KubeClient.CustomResourceDefinitions().Patch(
context.TODO(), crd.Name, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil {
return fmt.Errorf("could not update customResourceDefinition %q: %v", crd.Name, err)
} }
} else { } else {
c.logger.Infof("customResourceDefinition %q has been registered", crd.Name) c.logger.Infof("customResourceDefinition %q has been registered", crd.Name)
} }
return wait.Poll(c.config.CRDReadyWaitInterval, c.config.CRDReadyWaitTimeout, func() (bool, error) { return wait.Poll(c.config.CRDReadyWaitInterval, c.config.CRDReadyWaitTimeout, func() (bool, error) {
c, err := c.KubeClient.CustomResourceDefinitions().Get(context.TODO(), crd.Name, metav1.GetOptions{}) c, err := c.KubeClient.CustomResourceDefinitions().Get(context.TODO(), desiredCrd.Name, metav1.GetOptions{})
if err != nil { if err != nil {
return false, err return false, err
} }
@ -96,12 +103,12 @@ func (c *Controller) createOperatorCRD(crd *apiextv1.CustomResourceDefinition) e
}) })
} }
func (c *Controller) createPostgresCRD(enableValidation *bool) error { func (c *Controller) createPostgresCRD() error {
return c.createOperatorCRD(acidv1.PostgresCRD(enableValidation)) return c.createOperatorCRD(acidv1.PostgresCRD(c.opConfig.CRDCategories))
} }
func (c *Controller) createConfigurationCRD(enableValidation *bool) error { func (c *Controller) createConfigurationCRD() error {
return c.createOperatorCRD(acidv1.ConfigurationCRD(enableValidation)) return c.createOperatorCRD(acidv1.ConfigurationCRD(c.opConfig.CRDCategories))
} }
func readDecodedRole(s string) (*spec.PgUser, error) { func readDecodedRole(s string) (*spec.PgUser, error) {

View File

@ -55,6 +55,7 @@ type PgUser struct {
MemberOf []string `yaml:"inrole"` MemberOf []string `yaml:"inrole"`
Parameters map[string]string `yaml:"db_parameters"` Parameters map[string]string `yaml:"db_parameters"`
AdminRole string `yaml:"admin_role"` AdminRole string `yaml:"admin_role"`
IsDbOwner bool `yaml:"is_db_owner"`
Deleted bool `yaml:"deleted"` Deleted bool `yaml:"deleted"`
} }

View File

@ -20,6 +20,7 @@ type CRD struct {
RepairPeriod time.Duration `name:"repair_period" default:"5m"` RepairPeriod time.Duration `name:"repair_period" default:"5m"`
EnableCRDRegistration *bool `name:"enable_crd_registration" default:"true"` EnableCRDRegistration *bool `name:"enable_crd_registration" default:"true"`
EnableCRDValidation *bool `name:"enable_crd_validation" default:"true"` EnableCRDValidation *bool `name:"enable_crd_validation" default:"true"`
CRDCategories []string `name:"crd_categories" default:"all"`
} }
// Resources describes kubernetes resource specific configuration parameters // Resources describes kubernetes resource specific configuration parameters
@ -55,6 +56,7 @@ type Resources struct {
PodEnvironmentConfigMap spec.NamespacedName `name:"pod_environment_configmap"` PodEnvironmentConfigMap spec.NamespacedName `name:"pod_environment_configmap"`
PodEnvironmentSecret string `name:"pod_environment_secret"` PodEnvironmentSecret string `name:"pod_environment_secret"`
NodeReadinessLabel map[string]string `name:"node_readiness_label" default:""` NodeReadinessLabel map[string]string `name:"node_readiness_label" default:""`
NodeReadinessLabelMerge string `name:"node_readiness_label_merge" default:"OR"`
MaxInstances int32 `name:"max_instances" default:"-1"` MaxInstances int32 `name:"max_instances" default:"-1"`
MinInstances int32 `name:"min_instances" default:"-1"` MinInstances int32 `name:"min_instances" default:"-1"`
ShmVolume *bool `name:"enable_shm_volume" default:"true"` ShmVolume *bool `name:"enable_shm_volume" default:"true"`
@ -99,6 +101,9 @@ type Auth struct {
InfrastructureRolesDefs string `name:"infrastructure_roles_secrets"` InfrastructureRolesDefs string `name:"infrastructure_roles_secrets"`
SuperUsername string `name:"super_username" default:"postgres"` SuperUsername string `name:"super_username" default:"postgres"`
ReplicationUsername string `name:"replication_username" default:"standby"` ReplicationUsername string `name:"replication_username" default:"standby"`
EnablePasswordRotation bool `name:"enable_password_rotation" default:"false"`
PasswordRotationInterval uint32 `name:"password_rotation_interval" default:"90"`
PasswordRotationUserRetention uint32 `name:"password_rotation_user_retention" default:"180"`
} }
// Scalyr holds the configuration for the Scalyr Agent sidecar for log shipping: // Scalyr holds the configuration for the Scalyr Agent sidecar for log shipping:

View File

@ -18,6 +18,7 @@ const (
alterUserRenameSQL = `ALTER ROLE "%s" RENAME TO "%s%s"` alterUserRenameSQL = `ALTER ROLE "%s" RENAME TO "%s%s"`
alterRoleResetAllSQL = `ALTER ROLE "%s" RESET ALL` alterRoleResetAllSQL = `ALTER ROLE "%s" RESET ALL`
alterRoleSetSQL = `ALTER ROLE "%s" SET %s TO %s` alterRoleSetSQL = `ALTER ROLE "%s" SET %s TO %s`
dropUserSQL = `SET LOCAL synchronous_commit = 'local'; DROP ROLE "%s";`
grantToUserSQL = `GRANT %s TO "%s"` grantToUserSQL = `GRANT %s TO "%s"`
doBlockStmt = `SET LOCAL synchronous_commit = 'local'; DO $$ BEGIN %s; END;$$;` doBlockStmt = `SET LOCAL synchronous_commit = 'local'; DO $$ BEGIN %s; END;$$;`
passwordTemplate = "ENCRYPTED PASSWORD '%s'" passwordTemplate = "ENCRYPTED PASSWORD '%s'"
@ -288,3 +289,13 @@ func quoteParameterValue(name, val string) string {
} }
return fmt.Sprintf(`'%s'`, strings.Trim(val, " ")) return fmt.Sprintf(`'%s'`, strings.Trim(val, " "))
} }
// DropPgUser to remove user created by the operator e.g. for password rotation
func DropPgUser(user string, db *sql.DB) error {
query := fmt.Sprintf(dropUserSQL, user)
if _, err := db.Exec(query); err != nil { // TODO: Try several times
return err
}
return nil
}