From e2a9b0391343e6c7251d72f5612306aa6af46a3e Mon Sep 17 00:00:00 2001 From: Felix Kunde Date: Thu, 20 Feb 2020 16:21:21 +0100 Subject: [PATCH 1/7] bump spilo version to latest release (#836) --- charts/postgres-operator/values-crd.yaml | 2 +- charts/postgres-operator/values.yaml | 2 +- manifests/complete-postgres-manifest.yaml | 2 +- manifests/configmap.yaml | 2 +- manifests/postgresql-operator-default-configuration.yaml | 2 +- pkg/util/config/config.go | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/charts/postgres-operator/values-crd.yaml b/charts/postgres-operator/values-crd.yaml index 08c255a04..195a03380 100644 --- a/charts/postgres-operator/values-crd.yaml +++ b/charts/postgres-operator/values-crd.yaml @@ -24,7 +24,7 @@ configGeneral: # etcd connection string for Patroni. Empty uses K8s-native DCS. etcd_host: "" # Spilo docker image - docker_image: registry.opensource.zalan.do/acid/spilo-cdp-12:1.6-p16 + docker_image: registry.opensource.zalan.do/acid/spilo-12:1.6-p2 # max number of instances in Postgres cluster. -1 = no limit min_instances: -1 # min number of instances in Postgres cluster. -1 = no limit diff --git a/charts/postgres-operator/values.yaml b/charts/postgres-operator/values.yaml index 78624e0bd..8b52a7d67 100644 --- a/charts/postgres-operator/values.yaml +++ b/charts/postgres-operator/values.yaml @@ -24,7 +24,7 @@ configGeneral: # etcd connection string for Patroni. Empty uses K8s-native DCS. etcd_host: "" # Spilo docker image - docker_image: registry.opensource.zalan.do/acid/spilo-cdp-12:1.6-p16 + docker_image: registry.opensource.zalan.do/acid/spilo-12:1.6-p2 # max number of instances in Postgres cluster. -1 = no limit min_instances: "-1" # min number of instances in Postgres cluster. -1 = no limit diff --git a/manifests/complete-postgres-manifest.yaml b/manifests/complete-postgres-manifest.yaml index 9e3b891c3..5ae817ca3 100644 --- a/manifests/complete-postgres-manifest.yaml +++ b/manifests/complete-postgres-manifest.yaml @@ -5,7 +5,7 @@ metadata: # labels: # environment: demo spec: - dockerImage: registry.opensource.zalan.do/acid/spilo-cdp-12:1.6-p16 + dockerImage: registry.opensource.zalan.do/acid/spilo-12:1.6-p2 teamId: "acid" volume: size: 1Gi diff --git a/manifests/configmap.yaml b/manifests/configmap.yaml index 4289a134c..aa7bef034 100644 --- a/manifests/configmap.yaml +++ b/manifests/configmap.yaml @@ -19,7 +19,7 @@ data: # default_cpu_request: 100m # default_memory_limit: 500Mi # default_memory_request: 100Mi - docker_image: registry.opensource.zalan.do/acid/spilo-cdp-12:1.6-p16 + docker_image: registry.opensource.zalan.do/acid/spilo-12:1.6-p2 # enable_admin_role_for_users: "true" # enable_crd_validation: "true" # enable_database_access: "true" diff --git a/manifests/postgresql-operator-default-configuration.yaml b/manifests/postgresql-operator-default-configuration.yaml index 695a4e9c5..bdb131fc5 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -5,7 +5,7 @@ metadata: configuration: # enable_crd_validation: true etcd_host: "" - docker_image: registry.opensource.zalan.do/acid/spilo-cdp-12:1.6-p16 + docker_image: registry.opensource.zalan.do/acid/spilo-12:1.6-p2 # enable_shm_volume: true max_instances: -1 min_instances: -1 diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index ec4af6427..0e88c60d7 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -93,7 +93,7 @@ type Config struct { WatchedNamespace string `name:"watched_namespace"` // special values: "*" means 'watch all namespaces', the empty string "" means 'watch a namespace where operator is deployed to' EtcdHost string `name:"etcd_host" default:""` // special values: the empty string "" means Patroni will use K8s as a DCS - DockerImage string `name:"docker_image" default:"registry.opensource.zalan.do/acid/spilo-cdp-12:1.6-p16"` + DockerImage string `name:"docker_image" default:"registry.opensource.zalan.do/acid/spilo-12:1.6-p2"` Sidecars map[string]string `name:"sidecar_docker_images"` // default name `operator` enables backward compatibility with the older ServiceAccountName field PodServiceAccountName string `name:"pod_service_account_name" default:"postgres-pod"` From 7b94060d1754f12ba2466b8011bb84ea09ac051d Mon Sep 17 00:00:00 2001 From: Felix Kunde Date: Fri, 21 Feb 2020 16:36:23 +0100 Subject: [PATCH 2/7] fix validation for S3ForcePathStyle (#841) --- charts/postgres-operator/crds/postgresqls.yaml | 2 +- manifests/postgresql.crd.yaml | 2 +- pkg/apis/acid.zalan.do/v1/crds.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/postgres-operator/crds/postgresqls.yaml b/charts/postgres-operator/crds/postgresqls.yaml index b4b676236..af535e2c8 100644 --- a/charts/postgres-operator/crds/postgresqls.yaml +++ b/charts/postgres-operator/crds/postgresqls.yaml @@ -94,7 +94,7 @@ spec: s3_secret_access_key: type: string s3_force_path_style: - type: string + type: boolean s3_wal_path: type: string timestamp: diff --git a/manifests/postgresql.crd.yaml b/manifests/postgresql.crd.yaml index 276bc94b8..453916b26 100644 --- a/manifests/postgresql.crd.yaml +++ b/manifests/postgresql.crd.yaml @@ -58,7 +58,7 @@ spec: s3_secret_access_key: type: string s3_force_path_style: - type: string + type: boolean s3_wal_path: type: string timestamp: diff --git a/pkg/apis/acid.zalan.do/v1/crds.go b/pkg/apis/acid.zalan.do/v1/crds.go index 4cfc9a9e6..28dfa1566 100644 --- a/pkg/apis/acid.zalan.do/v1/crds.go +++ b/pkg/apis/acid.zalan.do/v1/crds.go @@ -160,7 +160,7 @@ var PostgresCRDResourceValidation = apiextv1beta1.CustomResourceValidation{ Type: "string", }, "s3_force_path_style": { - Type: "string", + Type: "boolean", }, "s3_wal_path": { Type: "string", From b997e3682f2281188cacd0bf96ac4bca50d686f1 Mon Sep 17 00:00:00 2001 From: Felix Kunde Date: Mon, 24 Feb 2020 15:14:14 +0100 Subject: [PATCH 3/7] be more permissive with standbys (#842) * be more permissive with standbys * reflect feedback and updated docs --- docs/administrator.md | 10 +- docs/reference/operator_parameters.md | 6 +- docs/user.md | 134 ++++++++++++++++++-------- pkg/cluster/k8sres.go | 10 +- 4 files changed, 109 insertions(+), 51 deletions(-) diff --git a/docs/administrator.md b/docs/administrator.md index 3597b65ca..9d877c783 100644 --- a/docs/administrator.md +++ b/docs/administrator.md @@ -11,11 +11,11 @@ switchover (planned failover) of the master to the Pod with new minor version. The switch should usually take less than 5 seconds, still clients have to reconnect. -Major version upgrades are supported via [cloning](user.md#clone-directly). The -new cluster manifest must have a higher `version` string than the source cluster -and will be created from a basebackup. Depending of the cluster size, downtime -in this case can be significant as writes to the database should be stopped and -all WAL files should be archived first before cloning is started. +Major version upgrades are supported via [cloning](user.md#how-to-clone-an-existing-postgresql-cluster). +The new cluster manifest must have a higher `version` string than the source +cluster and will be created from a basebackup. Depending of the cluster size, +downtime in this case can be significant as writes to the database should be +stopped and all WAL files should be archived first before cloning is started. Note, that simply changing the version string in the `postgresql` manifest does not work at present and leads to errors. Neither Patroni nor Postgres Operator diff --git a/docs/reference/operator_parameters.md b/docs/reference/operator_parameters.md index ca972c22b..ad519b657 100644 --- a/docs/reference/operator_parameters.md +++ b/docs/reference/operator_parameters.md @@ -110,8 +110,10 @@ Those are top-level keys, containing both leaf keys and groups. * **min_instances** operator will run at least the number of instances for any given Postgres - cluster equal to the value of this parameter. When `-1` is specified, no - limits are applied. The default is `-1`. + cluster equal to the value of this parameter. Standby clusters can still run + with `numberOfInstances: 1` as this is the [recommended setup](../user.md#setting-up-a-standby-cluster). + When `-1` is specified for `min_instances`, no limits are applied. The default + is `-1`. * **resync_period** period between consecutive sync requests. The default is `30m`. diff --git a/docs/user.md b/docs/user.md index e1baf9ad1..91a010b9c 100644 --- a/docs/user.md +++ b/docs/user.md @@ -254,29 +254,22 @@ spec: ## How to clone an existing PostgreSQL cluster -You can spin up a new cluster as a clone of the existing one, using a clone +You can spin up a new cluster as a clone of the existing one, using a `clone` section in the spec. There are two options here: -* Clone directly from a source cluster using `pg_basebackup` -* Clone from an S3 bucket +* Clone from an S3 bucket (recommended) +* Clone directly from a source cluster -### Clone directly - -```yaml -spec: - clone: - cluster: "acid-batman" -``` - -Here `cluster` is a name of a source cluster that is going to be cloned. The -cluster to clone is assumed to be running and the clone procedure invokes -`pg_basebackup` from it. The operator will setup the cluster to be cloned to -connect to the service of the source cluster by name (if the cluster is called -test, then the connection string will look like host=test port=5432), which -means that you can clone only from clusters within the same namespace. +Note, that cloning can also be used for [major version upgrades](administrator.md#minor-and-major-version-upgrade) +of PostgreSQL. ### Clone from S3 +Cloning from S3 has the advantage that there is no impact on your production +database. A new Postgres cluster is created by restoring the data of another +source cluster. If you create it in the same Kubernetes environment, use a +different name. + ```yaml spec: clone: @@ -287,7 +280,8 @@ spec: Here `cluster` is a name of a source cluster that is going to be cloned. A new cluster will be cloned from S3, using the latest backup before the `timestamp`. -In this case, `uid` field is also mandatory - operator will use it to find a +Note, that a time zone is required for `timestamp` in the format of +00:00 which +is UTC. The `uid` field is also mandatory. The operator will use it to find a correct key inside an S3 bucket. You can find this field in the metadata of the source cluster: @@ -299,9 +293,6 @@ metadata: uid: efd12e58-5786-11e8-b5a7-06148230260c ``` -Note that timezone is required for `timestamp`. Otherwise, offset is relative -to UTC, see [RFC 3339 section 5.6) 3339 section 5.6](https://www.ietf.org/rfc/rfc3339.txt). - For non AWS S3 following settings can be set to support cloning from other S3 implementations: @@ -317,14 +308,35 @@ spec: s3_force_path_style: true ``` +### Clone directly + +Another way to get a fresh copy of your source DB cluster is via basebackup. To +use this feature simply leave out the timestamp field from the clone section. +The operator will connect to the service of the source cluster by name. If the +cluster is called test, then the connection string will look like host=test +port=5432), which means that you can clone only from clusters within the same +namespace. + +```yaml +spec: + clone: + cluster: "acid-batman" +``` + +Be aware that on a busy source database this can result in an elevated load! + ## Setting up a standby cluster -Standby clusters are like normal cluster but they are streaming from a remote -cluster. As the first version of this feature, the only scenario covered by -operator is to stream from a WAL archive of the master. Following the more -popular infrastructure of using Amazon's S3 buckets, it is mentioned as -`s3_wal_path` here. To start a cluster as standby add the following `standby` -section in the YAML file: +Standby cluster is a [Patroni feature](https://github.com/zalando/patroni/blob/master/docs/replica_bootstrap.rst#standby-cluster) +that first clones a database, and keeps replicating changes afterwards. As the +replication is happening by the means of archived WAL files (stored on S3 or +the equivalent of other cloud providers), the standby cluster can exist in a +different location than its source database. Unlike cloning, the PostgreSQL +version between source and target cluster has to be the same. + +To start a cluster as standby, add the following `standby` section in the YAML +file and specify the S3 bucket path. An empty path will result in an error and +no statefulset will be created. ```yaml spec: @@ -332,20 +344,62 @@ spec: s3_wal_path: "s3 bucket path to the master" ``` -Things to note: +At the moment, the operator only allows to stream from the WAL archive of the +master. Thus, it is recommended to deploy standby clusters with only [one pod](../manifests/standby-manifest.yaml#L10). +You can raise the instance count when detaching. Note, that the same pod role +labels like for normal clusters are used: The standby leader is labeled as +`master`. -- An empty string in the `s3_wal_path` field of the standby cluster will result - in an error and no statefulset will be created. -- Only one pod can be deployed for stand-by cluster. -- To manually promote the standby_cluster, use `patronictl` and remove config - entry. -- There is no way to transform a non-standby cluster to a standby cluster - through the operator. Adding the standby section to the manifest of a running - Postgres cluster will have no effect. However, it can be done through Patroni - by adding the [standby_cluster](https://github.com/zalando/patroni/blob/bd2c54581abb42a7d3a3da551edf0b8732eefd27/docs/replica_bootstrap.rst#standby-cluster) - section using `patronictl edit-config`. Note that the transformed standby - cluster will not be doing any streaming. It will be in standby mode and allow - read-only transactions only. +### Providing credentials of source cluster + +A standby cluster is replicating the data (including users and passwords) from +the source database and is read-only. The system and application users (like +standby, postgres etc.) all have a password that does not match the credentials +stored in secrets which are created by the operator. One solution is to create +secrets beforehand and paste in the credentials of the source cluster. +Otherwise, you will see errors in the Postgres logs saying users cannot log in +and the operator logs will complain about not being able to sync resources. +This, however, can safely be ignored as it will be sorted out once the cluster +is detached from the source (and it’s still harmless if you don’t plan to). + +You can also edit the secrets afterwards. Find them by: + +```bash +kubectl get secrets --all-namespaces | grep +``` + +### Promote the standby + +One big advantage of standby clusters is that they can be promoted to a proper +database cluster. This means it will stop replicating changes from the source, +and start accept writes itself. This mechanism makes it possible to move +databases from one place to another with minimal downtime. Currently, the +operator does not support promoting a standby cluster. It has to be done +manually using `patronictl edit-config` inside the postgres container of the +standby leader pod. Remove the following lines from the YAML structure and the +leader promotion happens immediately. Before doing so, make sure that the +standby is not behind the source database. + +```yaml +standby_cluster: + create_replica_methods: + - bootstrap_standby_with_wale + - basebackup_fast_xlog + restore_command: envdir "/home/postgres/etc/wal-e.d/env-standby" /scripts/restore_command.sh + "%f" "%p" +``` + +Finally, remove the `standby` section from the postgres cluster manifest. + +### Turn a normal cluster into a standby + +There is no way to transform a non-standby cluster to a standby cluster through +the operator. Adding the `standby` section to the manifest of a running +Postgres cluster will have no effect. But, as explained in the previous +paragraph it can be done manually through `patronictl edit-config`. This time, +by adding the `standby_cluster` section to the Patroni configuration. However, +the transformed standby cluster will not be doing any streaming. It will be in +standby mode and allow read-only transactions only. ## Sidecar Support diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 4468c8428..e2251a67c 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -1048,11 +1048,13 @@ func (c *Cluster) getNumberOfInstances(spec *acidv1.PostgresSpec) int32 { cur := spec.NumberOfInstances newcur := cur - /* Limit the max number of pods to one, if this is standby-cluster */ if spec.StandbyCluster != nil { - c.logger.Info("Standby cluster can have maximum of 1 pod") - min = 1 - max = 1 + if newcur == 1 { + min = newcur + max = newcur + } else { + c.logger.Warningf("operator only supports standby clusters with 1 pod") + } } if max >= 0 && newcur > max { newcur = max From fb9ef11e4e0199ca0bde1d87f0b87285e8077917 Mon Sep 17 00:00:00 2001 From: Felix Kunde Date: Mon, 24 Feb 2020 17:48:14 +0100 Subject: [PATCH 4/7] align UI pipeline with operator (#844) * align UI pipeline with operator --- delivery.yaml | 21 +++++++-------------- ui/Makefile | 13 +++++++++---- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/delivery.yaml b/delivery.yaml index be35d3e27..144448ea9 100644 --- a/delivery.yaml +++ b/delivery.yaml @@ -66,20 +66,13 @@ pipeline: - desc: 'Build and push Docker image' cmd: | cd ui - image_base='registry-write.opensource.zalan.do/acid/postgres-operator-ui' - if [[ "${CDP_TARGET_BRANCH}" == 'master' && -z "${CDP_PULL_REQUEST_NUMBER}" ]] + IS_PR_BUILD=${CDP_PULL_REQUEST_NUMBER+"true"} + if [[ ${CDP_TARGET_BRANCH} == "master" && ${IS_PR_BUILD} != "true" ]] then - image="${image_base}" + IMAGE=registry-write.opensource.zalan.do/acid/postgres-operator-ui else - image="${image_base}-test" + IMAGE=registry-write.opensource.zalan.do/acid/postgres-operator-ui-test fi - image_with_tag="${image}:c${CDP_BUILD_VERSION}" - - if docker pull "${image}" - then - docker build --cache-from="${image}" -t "${image_with_tag}" . - else - docker build -t "${image_with_tag}" . - fi - - docker push "${image_with_tag}" + export IMAGE + make docker + make push diff --git a/ui/Makefile b/ui/Makefile index f1cf16840..e7d5df674 100644 --- a/ui/Makefile +++ b/ui/Makefile @@ -5,9 +5,13 @@ VERSION ?= $(shell git describe --tags --always --dirty) TAG ?= $(VERSION) GITHEAD = $(shell git rev-parse --short HEAD) GITURL = $(shell git config --get remote.origin.url) -GITSTATU = $(shell git status --porcelain || echo 'no changes') +GITSTATUS = $(shell git status --porcelain || echo 'no changes') TTYFLAGS = $(shell test -t 0 && echo '-it') +ifdef CDP_PULL_REQUEST_NUMBER + CDP_TAG := -${CDP_BUILD_VERSION} +endif + default: docker clean: @@ -24,11 +28,12 @@ docker: appjs echo `(env)` echo "Tag ${TAG}" echo "Version ${VERSION}" + echo "CDP tag ${CDP_TAG}" echo "git describe $(shell git describe --tags --always --dirty)" - docker build --rm -t "$(IMAGE):$(TAG)" -f Dockerfile . + docker build --rm -t "$(IMAGE):$(TAG)$(CDP_TAG)" -f Dockerfile . -push: docker - docker push "$(IMAGE):$(TAG)" +push: + docker push "$(IMAGE):$(TAG)$(CDP_TAG)" mock: docker run -it -p 8080:8080 "$(IMAGE):$(TAG)" --mock From b24da3201ceeeb8e23ffbb4fd99076822f388898 Mon Sep 17 00:00:00 2001 From: Felix Kunde Date: Tue, 25 Feb 2020 09:50:54 +0100 Subject: [PATCH 5/7] bump version to 1.4.0 + some polishing (#839) * bump version to 1.4.0 + some polishing * align version for UI chart * update user docs to warn for standby replicas * minor log message changes for RBAC resources --- charts/postgres-operator-ui/Chart.yaml | 4 +-- charts/postgres-operator-ui/index.yaml | 29 ++++++++++++++++++ .../postgres-operator-ui-1.4.0.tgz | Bin 0 -> 3517 bytes charts/postgres-operator-ui/values.yaml | 2 +- charts/postgres-operator/Chart.yaml | 4 +-- charts/postgres-operator/index.yaml | 28 +++++++++++++++-- .../postgres-operator-1.4.0.tgz | Bin 0 -> 42200 bytes .../templates/clusterrole.yaml | 6 ++-- charts/postgres-operator/values-crd.yaml | 8 ++++- charts/postgres-operator/values.yaml | 8 ++++- docs/user.md | 11 ++++--- manifests/configmap.yaml | 2 ++ manifests/operator-service-account-rbac.yaml | 6 ++-- manifests/postgres-operator.yaml | 2 +- pkg/controller/controller.go | 2 +- pkg/controller/postgresql.go | 14 ++++----- pkg/util/config/config.go | 11 +++---- 17 files changed, 102 insertions(+), 35 deletions(-) create mode 100644 charts/postgres-operator-ui/index.yaml create mode 100644 charts/postgres-operator-ui/postgres-operator-ui-1.4.0.tgz create mode 100644 charts/postgres-operator/postgres-operator-1.4.0.tgz diff --git a/charts/postgres-operator-ui/Chart.yaml b/charts/postgres-operator-ui/Chart.yaml index 4418675b6..a6e46ab3e 100644 --- a/charts/postgres-operator-ui/Chart.yaml +++ b/charts/postgres-operator-ui/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v1 name: postgres-operator-ui -version: 0.1.0 -appVersion: 1.3.0 +version: 1.4.0 +appVersion: 1.4.0 home: https://github.com/zalando/postgres-operator description: Postgres Operator UI provides a graphical interface for a convenient database-as-a-service user experience keywords: diff --git a/charts/postgres-operator-ui/index.yaml b/charts/postgres-operator-ui/index.yaml new file mode 100644 index 000000000..0cd03d6e5 --- /dev/null +++ b/charts/postgres-operator-ui/index.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +entries: + postgres-operator-ui: + - apiVersion: v1 + appVersion: 1.4.0 + created: "2020-02-24T15:32:47.610967635+01:00" + description: Postgres Operator UI provides a graphical interface for a convenient + database-as-a-service user experience + digest: 00e0eff7056d56467cd5c975657fbb76c8d01accd25a4b7aca81bc42aeac961d + home: https://github.com/zalando/postgres-operator + keywords: + - postgres + - operator + - ui + - cloud-native + - patroni + - spilo + maintainers: + - email: opensource@zalando.de + name: Zalando + - email: sk@sik-net.de + name: siku4 + name: postgres-operator-ui + sources: + - https://github.com/zalando/postgres-operator + urls: + - postgres-operator-ui-1.4.0.tgz + version: 1.4.0 +generated: "2020-02-24T15:32:47.610348278+01:00" diff --git a/charts/postgres-operator-ui/postgres-operator-ui-1.4.0.tgz b/charts/postgres-operator-ui/postgres-operator-ui-1.4.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..8d1276dd16ab28ad5d176c810c86ad617c682359 GIT binary patch literal 3517 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 literal 0 HcmV?d00001 diff --git a/charts/postgres-operator/templates/clusterrole.yaml b/charts/postgres-operator/templates/clusterrole.yaml index 9a4165797..7b3dd462d 100644 --- a/charts/postgres-operator/templates/clusterrole.yaml +++ b/charts/postgres-operator/templates/clusterrole.yaml @@ -63,9 +63,9 @@ rules: - secrets verbs: - create - - update - delete - get + - update # to check nodes for node readiness label - apiGroups: - "" @@ -102,9 +102,9 @@ rules: - delete - get - list - - watch - - update - patch + - update + - watch # to resize the filesystem in Spilo pods when increasing volume size - apiGroups: - "" diff --git a/charts/postgres-operator/values-crd.yaml b/charts/postgres-operator/values-crd.yaml index 195a03380..b5d561807 100644 --- a/charts/postgres-operator/values-crd.yaml +++ b/charts/postgres-operator/values-crd.yaml @@ -1,7 +1,7 @@ image: registry: registry.opensource.zalan.do repository: acid/postgres-operator - tag: v1.3.1 + tag: v1.4.0 pullPolicy: "IfNotPresent" # Optionally specify an array of imagePullSecrets. @@ -100,8 +100,14 @@ configKubernetes: pod_management_policy: "ordered_ready" # label assigned to the Postgres pods (and services/endpoints) pod_role_label: spilo-role + # service account definition as JSON/YAML string to be used by postgres cluster pods + # pod_service_account_definition: "" + # name of service account to be used by postgres cluster pods pod_service_account_name: "postgres-pod" + # role binding definition as JSON/YAML string to be used by pod service account + # pod_service_account_role_binding_definition: "" + # Postgres pods are terminated forcefully after this timeout pod_terminate_grace_period: 5m # template for database user secrets generated by the operator diff --git a/charts/postgres-operator/values.yaml b/charts/postgres-operator/values.yaml index 8b52a7d67..07ba76285 100644 --- a/charts/postgres-operator/values.yaml +++ b/charts/postgres-operator/values.yaml @@ -1,7 +1,7 @@ image: registry: registry.opensource.zalan.do repository: acid/postgres-operator - tag: v1.3.1 + tag: v1.4.0 pullPolicy: "IfNotPresent" # Optionally specify an array of imagePullSecrets. @@ -93,8 +93,14 @@ configKubernetes: pod_management_policy: "ordered_ready" # label assigned to the Postgres pods (and services/endpoints) pod_role_label: spilo-role + # service account definition as JSON/YAML string to be used by postgres cluster pods + # pod_service_account_definition: "" + # name of service account to be used by postgres cluster pods pod_service_account_name: "postgres-pod" + # role binding definition as JSON/YAML string to be used by pod service account + # pod_service_account_role_binding_definition: "" + # Postgres pods are terminated forcefully after this timeout pod_terminate_grace_period: 5m # template for database user secrets generated by the operator diff --git a/docs/user.md b/docs/user.md index 91a010b9c..295c149bd 100644 --- a/docs/user.md +++ b/docs/user.md @@ -359,13 +359,16 @@ stored in secrets which are created by the operator. One solution is to create secrets beforehand and paste in the credentials of the source cluster. Otherwise, you will see errors in the Postgres logs saying users cannot log in and the operator logs will complain about not being able to sync resources. -This, however, can safely be ignored as it will be sorted out once the cluster -is detached from the source (and it’s still harmless if you don’t plan to). -You can also edit the secrets afterwards. Find them by: +When you only run a standby leader, you can safely ignore this, as it will be +sorted out once the cluster is detached from the source. It is also harmless if +you don’t plan it. But, when you created a standby replica, too, fix the +credentials right away. WAL files will pile up on the standby leader if no +connection can be established between standby replica(s). You can also edit the +secrets after their creation. Find them by: ```bash -kubectl get secrets --all-namespaces | grep +kubectl get secrets --all-namespaces | grep ``` ### Promote the standby diff --git a/manifests/configmap.yaml b/manifests/configmap.yaml index aa7bef034..0300b5495 100644 --- a/manifests/configmap.yaml +++ b/manifests/configmap.yaml @@ -63,7 +63,9 @@ data: pod_label_wait_timeout: 10m pod_management_policy: "ordered_ready" pod_role_label: spilo-role + # pod_service_account_definition: "" pod_service_account_name: "postgres-pod" + # pod_service_account_role_binding_definition: "" pod_terminate_grace_period: 5m # postgres_superuser_teams: "postgres_superusers" # protected_role_names: "admin" diff --git a/manifests/operator-service-account-rbac.yaml b/manifests/operator-service-account-rbac.yaml index 80fcd89ef..e5bc49f83 100644 --- a/manifests/operator-service-account-rbac.yaml +++ b/manifests/operator-service-account-rbac.yaml @@ -64,9 +64,9 @@ rules: - secrets verbs: - create - - update - delete - get + - update # to check nodes for node readiness label - apiGroups: - "" @@ -103,9 +103,9 @@ rules: - delete - get - list - - watch - - update - patch + - update + - watch # to resize the filesystem in Spilo pods when increasing volume size - apiGroups: - "" diff --git a/manifests/postgres-operator.yaml b/manifests/postgres-operator.yaml index e3bc3e3e4..63f17d9fa 100644 --- a/manifests/postgres-operator.yaml +++ b/manifests/postgres-operator.yaml @@ -15,7 +15,7 @@ spec: serviceAccountName: postgres-operator containers: - name: postgres-operator - image: registry.opensource.zalan.do/acid/postgres-operator:v1.3.1 + image: registry.opensource.zalan.do/acid/postgres-operator:v1.4.0 imagePullPolicy: IfNotPresent resources: requests: diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 3c49b9a13..140d2bc4e 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -224,7 +224,7 @@ func (c *Controller) initRoleBinding() { switch { case err != nil: - panic(fmt.Errorf("unable to parse the definition of the role binding for the pod service account definition from the operator configuration: %v", err)) + panic(fmt.Errorf("unable to parse the role binding definition from the operator configuration: %v", err)) case groupVersionKind.Kind != "RoleBinding": panic(fmt.Errorf("role binding definition in the operator configuration defines another type of resource: %v", groupVersionKind.Kind)) default: diff --git a/pkg/controller/postgresql.go b/pkg/controller/postgresql.go index 8e8f9ae85..96d12bb9f 100644 --- a/pkg/controller/postgresql.go +++ b/pkg/controller/postgresql.go @@ -505,11 +505,11 @@ func (c *Controller) submitRBACCredentials(event ClusterEvent) error { namespace := event.NewSpec.GetNamespace() if err := c.createPodServiceAccount(namespace); err != nil { - return fmt.Errorf("could not create pod service account %v : %v", c.opConfig.PodServiceAccountName, err) + return fmt.Errorf("could not create pod service account %q : %v", c.opConfig.PodServiceAccountName, err) } if err := c.createRoleBindings(namespace); err != nil { - return fmt.Errorf("could not create role binding %v : %v", c.PodServiceAccountRoleBinding.Name, err) + return fmt.Errorf("could not create role binding %q : %v", c.PodServiceAccountRoleBinding.Name, err) } return nil } @@ -520,16 +520,16 @@ func (c *Controller) createPodServiceAccount(namespace string) error { _, err := c.KubeClient.ServiceAccounts(namespace).Get(podServiceAccountName, metav1.GetOptions{}) if k8sutil.ResourceNotFound(err) { - c.logger.Infof(fmt.Sprintf("creating pod service account in the namespace %v", namespace)) + c.logger.Infof(fmt.Sprintf("creating pod service account %q in the %q namespace", podServiceAccountName, namespace)) // get a separate copy of service account // to prevent a race condition when setting a namespace for many clusters sa := *c.PodServiceAccount if _, err = c.KubeClient.ServiceAccounts(namespace).Create(&sa); err != nil { - return fmt.Errorf("cannot deploy the pod service account %v defined in the config map to the %v namespace: %v", podServiceAccountName, namespace, err) + return fmt.Errorf("cannot deploy the pod service account %q defined in the configuration to the %q namespace: %v", podServiceAccountName, namespace, err) } - c.logger.Infof("successfully deployed the pod service account %v to the %v namespace", podServiceAccountName, namespace) + c.logger.Infof("successfully deployed the pod service account %q to the %q namespace", podServiceAccountName, namespace) } else if k8sutil.ResourceAlreadyExists(err) { return nil } @@ -545,14 +545,14 @@ func (c *Controller) createRoleBindings(namespace string) error { _, err := c.KubeClient.RoleBindings(namespace).Get(podServiceAccountRoleBindingName, metav1.GetOptions{}) if k8sutil.ResourceNotFound(err) { - c.logger.Infof("Creating the role binding %v in the namespace %v", podServiceAccountRoleBindingName, namespace) + c.logger.Infof("Creating the role binding %q in the %q namespace", podServiceAccountRoleBindingName, namespace) // get a separate copy of role binding // to prevent a race condition when setting a namespace for many clusters rb := *c.PodServiceAccountRoleBinding _, err = c.KubeClient.RoleBindings(namespace).Create(&rb) if err != nil { - return fmt.Errorf("cannot bind the pod service account %q defined in the config map to the cluster role in the %q namespace: %v", podServiceAccountName, namespace, err) + return fmt.Errorf("cannot bind the pod service account %q defined in the configuration to the cluster role in the %q namespace: %v", podServiceAccountName, namespace, err) } c.logger.Infof("successfully deployed the role binding for the pod service account %q to the %q namespace", podServiceAccountName, namespace) diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index 0e88c60d7..fee65be81 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -91,12 +91,11 @@ type Config struct { Scalyr LogicalBackup - WatchedNamespace string `name:"watched_namespace"` // special values: "*" means 'watch all namespaces', the empty string "" means 'watch a namespace where operator is deployed to' - EtcdHost string `name:"etcd_host" default:""` // special values: the empty string "" means Patroni will use K8s as a DCS - DockerImage string `name:"docker_image" default:"registry.opensource.zalan.do/acid/spilo-12:1.6-p2"` - Sidecars map[string]string `name:"sidecar_docker_images"` - // default name `operator` enables backward compatibility with the older ServiceAccountName field - PodServiceAccountName string `name:"pod_service_account_name" default:"postgres-pod"` + WatchedNamespace string `name:"watched_namespace"` // special values: "*" means 'watch all namespaces', the empty string "" means 'watch a namespace where operator is deployed to' + EtcdHost string `name:"etcd_host" default:""` // special values: the empty string "" means Patroni will use K8s as a DCS + DockerImage string `name:"docker_image" default:"registry.opensource.zalan.do/acid/spilo-12:1.6-p2"` + Sidecars map[string]string `name:"sidecar_docker_images"` + PodServiceAccountName string `name:"pod_service_account_name" default:"postgres-pod"` // value of this string must be valid JSON or YAML; see initPodServiceAccount PodServiceAccountDefinition string `name:"pod_service_account_definition" default:""` PodServiceAccountRoleBindingDefinition string `name:"pod_service_account_role_binding_definition" default:""` From 51909204fd7085f5b890cc4689221fece54ccecc Mon Sep 17 00:00:00 2001 From: Hengchu Zhang Date: Fri, 28 Feb 2020 08:13:58 -0500 Subject: [PATCH 6/7] Change `logging_rest_api.api_port` to `8080` instead of `8008` (#848) The documentation states that the default operator REST service is at port `8080`, but the current default CRD based configuration is `8008`. Changing the default config to match documentation. --- manifests/postgresql-operator-default-configuration.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/postgresql-operator-default-configuration.yaml b/manifests/postgresql-operator-default-configuration.yaml index bdb131fc5..33838b2a9 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -110,7 +110,7 @@ configuration: log_statement: all # teams_api_url: "" logging_rest_api: - api_port: 8008 + api_port: 8080 cluster_history_entries: 1000 ring_log_lines: 100 scalyr: From ae2a38d62a7202f9bda9070a8f9dd0406557fb0d Mon Sep 17 00:00:00 2001 From: Felix Kunde Date: Fri, 6 Mar 2020 12:55:34 +0100 Subject: [PATCH 7/7] add e2e test for node readiness label (#846) * add e2e test for node readiness label * refactoring and order tests alphabetically * always wait for replica after failover --- e2e/tests/test_e2e.py | 372 +++++++++++++++++++++++++---------------- pkg/controller/node.go | 8 +- 2 files changed, 235 insertions(+), 145 deletions(-) diff --git a/e2e/tests/test_e2e.py b/e2e/tests/test_e2e.py index 12106601e..6760e815d 100644 --- a/e2e/tests/test_e2e.py +++ b/e2e/tests/test_e2e.py @@ -57,6 +57,7 @@ class EndToEndTestCase(unittest.TestCase): k8s.create_with_kubectl("manifests/minimal-postgres-manifest.yaml") k8s.wait_for_pod_start('spilo-role=master') + k8s.wait_for_pod_start('spilo-role=replica') @timeout_decorator.timeout(TEST_TIMEOUT_SEC) def test_enable_load_balancer(self): @@ -107,141 +108,6 @@ class EndToEndTestCase(unittest.TestCase): self.assertEqual(repl_svc_type, 'ClusterIP', "Expected ClusterIP service type for replica, found {}".format(repl_svc_type)) - @timeout_decorator.timeout(TEST_TIMEOUT_SEC) - def test_min_resource_limits(self): - ''' - Lower resource limits below configured minimum and let operator fix it - ''' - k8s = self.k8s - cluster_label = 'cluster-name=acid-minimal-cluster' - _, failover_targets = k8s.get_pg_nodes(cluster_label) - - # configure minimum boundaries for CPU and memory limits - minCPULimit = '500m' - minMemoryLimit = '500Mi' - patch_min_resource_limits = { - "data": { - "min_cpu_limit": minCPULimit, - "min_memory_limit": minMemoryLimit - } - } - k8s.update_config(patch_min_resource_limits) - - # lower resource limits below minimum - pg_patch_resources = { - "spec": { - "resources": { - "requests": { - "cpu": "10m", - "memory": "50Mi" - }, - "limits": { - "cpu": "200m", - "memory": "200Mi" - } - } - } - } - k8s.api.custom_objects_api.patch_namespaced_custom_object( - "acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", pg_patch_resources) - k8s.wait_for_master_failover(failover_targets) - - pods = k8s.api.core_v1.list_namespaced_pod( - 'default', label_selector='spilo-role=master,' + cluster_label).items - self.assert_master_is_unique() - masterPod = pods[0] - - self.assertEqual(masterPod.spec.containers[0].resources.limits['cpu'], minCPULimit, - "Expected CPU limit {}, found {}" - .format(minCPULimit, masterPod.spec.containers[0].resources.limits['cpu'])) - self.assertEqual(masterPod.spec.containers[0].resources.limits['memory'], minMemoryLimit, - "Expected memory limit {}, found {}" - .format(minMemoryLimit, masterPod.spec.containers[0].resources.limits['memory'])) - - @timeout_decorator.timeout(TEST_TIMEOUT_SEC) - def test_multi_namespace_support(self): - ''' - Create a customized Postgres cluster in a non-default namespace. - ''' - k8s = self.k8s - - with open("manifests/complete-postgres-manifest.yaml", 'r+') as f: - pg_manifest = yaml.safe_load(f) - pg_manifest["metadata"]["namespace"] = self.namespace - yaml.dump(pg_manifest, f, Dumper=yaml.Dumper) - - k8s.create_with_kubectl("manifests/complete-postgres-manifest.yaml") - k8s.wait_for_pod_start("spilo-role=master", self.namespace) - self.assert_master_is_unique(self.namespace, "acid-test-cluster") - - @timeout_decorator.timeout(TEST_TIMEOUT_SEC) - def test_scaling(self): - ''' - Scale up from 2 to 3 and back to 2 pods by updating the Postgres manifest at runtime. - ''' - k8s = self.k8s - labels = "cluster-name=acid-minimal-cluster" - - k8s.wait_for_pg_to_scale(3) - self.assertEqual(3, k8s.count_pods_with_label(labels)) - self.assert_master_is_unique() - - k8s.wait_for_pg_to_scale(2) - self.assertEqual(2, k8s.count_pods_with_label(labels)) - self.assert_master_is_unique() - - @timeout_decorator.timeout(TEST_TIMEOUT_SEC) - def test_taint_based_eviction(self): - ''' - Add taint "postgres=:NoExecute" to node with master. This must cause a failover. - ''' - k8s = self.k8s - cluster_label = 'cluster-name=acid-minimal-cluster' - - # get nodes of master and replica(s) (expected target of new master) - current_master_node, failover_targets = k8s.get_pg_nodes(cluster_label) - num_replicas = len(failover_targets) - - # if all pods live on the same node, failover will happen to other worker(s) - failover_targets = [x for x in failover_targets if x != current_master_node] - if len(failover_targets) == 0: - nodes = k8s.api.core_v1.list_node() - for n in nodes.items: - if "node-role.kubernetes.io/master" not in n.metadata.labels and n.metadata.name != current_master_node: - failover_targets.append(n.metadata.name) - - # taint node with postgres=:NoExecute to force failover - body = { - "spec": { - "taints": [ - { - "effect": "NoExecute", - "key": "postgres" - } - ] - } - } - - # patch node and test if master is failing over to one of the expected nodes - k8s.api.core_v1.patch_node(current_master_node, body) - k8s.wait_for_master_failover(failover_targets) - 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() - - # undo the tainting - body = { - "spec": { - "taints": [] - } - } - k8s.api.core_v1.patch_node(new_master_node, body) - @timeout_decorator.timeout(TEST_TIMEOUT_SEC) def test_logical_backup_cron_job(self): ''' @@ -306,6 +172,133 @@ class EndToEndTestCase(unittest.TestCase): self.assertEqual(0, len(jobs), "Expected 0 logical backup jobs, found {}".format(len(jobs))) + @timeout_decorator.timeout(TEST_TIMEOUT_SEC) + def test_min_resource_limits(self): + ''' + Lower resource limits below configured minimum and let operator fix it + ''' + k8s = self.k8s + cluster_label = 'cluster-name=acid-minimal-cluster' + labels = 'spilo-role=master,' + cluster_label + _, failover_targets = k8s.get_pg_nodes(cluster_label) + + # configure minimum boundaries for CPU and memory limits + minCPULimit = '500m' + minMemoryLimit = '500Mi' + patch_min_resource_limits = { + "data": { + "min_cpu_limit": minCPULimit, + "min_memory_limit": minMemoryLimit + } + } + k8s.update_config(patch_min_resource_limits) + + # lower resource limits below minimum + pg_patch_resources = { + "spec": { + "resources": { + "requests": { + "cpu": "10m", + "memory": "50Mi" + }, + "limits": { + "cpu": "200m", + "memory": "200Mi" + } + } + } + } + k8s.api.custom_objects_api.patch_namespaced_custom_object( + "acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", pg_patch_resources) + k8s.wait_for_pod_failover(failover_targets, labels) + k8s.wait_for_pod_start('spilo-role=replica') + + pods = k8s.api.core_v1.list_namespaced_pod( + 'default', label_selector=labels).items + self.assert_master_is_unique() + masterPod = pods[0] + + self.assertEqual(masterPod.spec.containers[0].resources.limits['cpu'], minCPULimit, + "Expected CPU limit {}, found {}" + .format(minCPULimit, masterPod.spec.containers[0].resources.limits['cpu'])) + self.assertEqual(masterPod.spec.containers[0].resources.limits['memory'], minMemoryLimit, + "Expected memory limit {}, found {}" + .format(minMemoryLimit, masterPod.spec.containers[0].resources.limits['memory'])) + + @timeout_decorator.timeout(TEST_TIMEOUT_SEC) + def test_multi_namespace_support(self): + ''' + Create a customized Postgres cluster in a non-default namespace. + ''' + k8s = self.k8s + + with open("manifests/complete-postgres-manifest.yaml", 'r+') as f: + pg_manifest = yaml.safe_load(f) + pg_manifest["metadata"]["namespace"] = self.namespace + yaml.dump(pg_manifest, f, Dumper=yaml.Dumper) + + k8s.create_with_kubectl("manifests/complete-postgres-manifest.yaml") + k8s.wait_for_pod_start("spilo-role=master", self.namespace) + self.assert_master_is_unique(self.namespace, "acid-test-cluster") + + @timeout_decorator.timeout(TEST_TIMEOUT_SEC) + def test_node_readiness_label(self): + ''' + Remove node readiness label from master node. This must cause a failover. + ''' + k8s = self.k8s + cluster_label = 'cluster-name=acid-minimal-cluster' + labels = 'spilo-role=master,' + cluster_label + readiness_label = 'lifecycle-status' + readiness_value = 'ready' + + # get nodes of master and replica(s) (expected target of new master) + 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) + + # add node_readiness_label to potential failover nodes + patch_readiness_label = { + "metadata": { + "labels": { + readiness_label: readiness_value + } + } + } + for failover_target in failover_targets: + 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 + patch_readiness_label_config = { + "data": { + "node_readiness_label": readiness_label + ':' + readiness_value, + } + } + k8s.update_config(patch_readiness_label_config) + new_master_node, new_replica_nodes = self.assert_failover( + current_master_node, num_replicas, failover_targets, cluster_label) + + # patch also node where master ran before + k8s.api.core_v1.patch_node(current_master_node, patch_readiness_label) + # toggle pod anti affinity to move replica away from master node + self.assert_distributed_pods(new_master_node, new_replica_nodes, cluster_label) + + @timeout_decorator.timeout(TEST_TIMEOUT_SEC) + def test_scaling(self): + ''' + Scale up from 2 to 3 and back to 2 pods by updating the Postgres manifest at runtime. + ''' + k8s = self.k8s + labels = "cluster-name=acid-minimal-cluster" + + k8s.wait_for_pg_to_scale(3) + self.assertEqual(3, k8s.count_pods_with_label(labels)) + self.assert_master_is_unique() + + k8s.wait_for_pg_to_scale(2) + self.assertEqual(2, k8s.count_pods_with_label(labels)) + self.assert_master_is_unique() + @timeout_decorator.timeout(TEST_TIMEOUT_SEC) def test_service_annotations(self): ''' @@ -346,18 +339,116 @@ class EndToEndTestCase(unittest.TestCase): } k8s.update_config(unpatch_custom_service_annotations) + @timeout_decorator.timeout(TEST_TIMEOUT_SEC) + def test_taint_based_eviction(self): + ''' + Add taint "postgres=:NoExecute" to node with master. This must cause a failover. + ''' + k8s = self.k8s + cluster_label = 'cluster-name=acid-minimal-cluster' + + # get nodes of master and replica(s) (expected target of new master) + 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) + + # taint node with postgres=:NoExecute to force failover + body = { + "spec": { + "taints": [ + { + "effect": "NoExecute", + "key": "postgres" + } + ] + } + } + + # patch node and test if master is failing over to one of the expected nodes + k8s.api.core_v1.patch_node(current_master_node, body) + new_master_node, new_replica_nodes = self.assert_failover( + current_master_node, num_replicas, failover_targets, cluster_label) + + # add toleration to pods + patch_toleration_config = { + "data": { + "toleration": "key:postgres,operator:Exists,effect:NoExecute" + } + } + k8s.update_config(patch_toleration_config) + + # toggle pod anti affinity to move replica away from master node + self.assert_distributed_pods(new_master_node, new_replica_nodes, cluster_label) + + 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 + + failover_targets = [x for x in replica_nodes if x != master_node] + if len(failover_targets) == 0: + nodes = k8s.api.core_v1.list_node() + for n in nodes.items: + if "node-role.kubernetes.io/master" not in n.metadata.labels and 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"): ''' Check that there is a single pod in the k8s cluster with the label "spilo-role=master" To be called manually after operations that affect pods ''' - k8s = self.k8s labels = 'spilo-role=master,cluster-name=' + clusterName 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)) + def assert_distributed_pods(self, master_node, replica_nodes, cluster_label): + ''' + 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). + ''' + k8s = self.k8s + failover_targets = self.get_failover_targets(master_node, replica_nodes) + + # enable pod anti affintiy in config map which should trigger movement of replica + patch_enable_antiaffinity = { + "data": { + "enable_pod_antiaffinity": "true" + } + } + k8s.update_config(patch_enable_antiaffinity) + self.assert_failover( + master_node, len(replica_nodes), failover_targets, cluster_label) + + # disable pod anti affintiy again + patch_disable_antiaffinity = { + "data": { + "enable_pod_antiaffinity": "false" + } + } + k8s.update_config(patch_disable_antiaffinity) + class K8sApi: @@ -445,15 +536,14 @@ class K8s: def count_pods_with_label(self, labels, namespace='default'): return len(self.api.core_v1.list_namespaced_pod(namespace, label_selector=labels).items) - def wait_for_master_failover(self, expected_master_nodes, namespace='default'): + def wait_for_pod_failover(self, failover_targets, labels, namespace='default'): pod_phase = 'Failing over' - new_master_node = '' - labels = 'spilo-role=master,cluster-name=acid-minimal-cluster' + new_pod_node = '' - while (pod_phase != 'Running') or (new_master_node not in expected_master_nodes): + while (pod_phase != 'Running') or (new_pod_node not in failover_targets): pods = self.api.core_v1.list_namespaced_pod(namespace, label_selector=labels).items if pods: - new_master_node = pods[0].spec.node_name + new_pod_node = pods[0].spec.node_name pod_phase = pods[0].status.phase time.sleep(self.RETRY_TIMEOUT_SEC) diff --git a/pkg/controller/node.go b/pkg/controller/node.go index 6f7befa27..8052458c3 100644 --- a/pkg/controller/node.go +++ b/pkg/controller/node.go @@ -5,7 +5,7 @@ import ( "time" "github.com/zalando/postgres-operator/pkg/util/retryutil" - "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" @@ -172,19 +172,19 @@ func (c *Controller) nodeDelete(obj interface{}) { } func (c *Controller) moveMasterPodsOffNode(node *v1.Node) { - + // retry to move master until configured timeout is reached err := retryutil.Retry(1*time.Minute, c.opConfig.MasterPodMoveTimeout, func() (bool, error) { err := c.attemptToMoveMasterPodsOffNode(node) if err != nil { - return false, fmt.Errorf("unable to move master pods off the unschedulable node; will retry after delay of 1 minute") + return false, err } return true, nil }, ) if err != nil { - c.logger.Warningf("failed to move master pods from the node %q: timeout of %v minutes expired", node.Name, c.opConfig.MasterPodMoveTimeout) + c.logger.Warningf("failed to move master pods from the node %q: %v", node.Name, err) } }