Merge branch 'master' into feat/enhanced-pooler-config

This commit is contained in:
Mitch Murphy 2026-07-17 09:46:18 -04:00 committed by GitHub
commit d762e707f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 246 additions and 4936 deletions

View File

@ -29,16 +29,19 @@ PKG := `go list ./... | grep -v /vendor/`
ifeq ($(DEBUG),1) ifeq ($(DEBUG),1)
DOCKERFILE = DebugDockerfile DOCKERFILE = DebugDockerfile
DEBUG_POSTFIX := -debug-$(shell date hhmmss) DEBUG_POSTFIX := -debug-$(shell date +"%H%M%S")
BUILD_FLAGS += -gcflags "-N -l" BUILD_FLAGS += -gcflags "-N -l"
else else
DOCKERFILE = Dockerfile DOCKERFILE = Dockerfile
endif endif
ifeq ($(FRESH),1) ifeq ($(FRESH),1)
DEBUG_FRESH=$(shell date +"%H-%M-%S") DEBUG_FRESH=$(shell date +"%H-%M-%S")
endif endif
SED := $(shell command -v gsed 2>/dev/null || command -v sed)
ifdef CDP_PULL_REQUEST_NUMBER ifdef CDP_PULL_REQUEST_NUMBER
CDP_TAG := -${CDP_BUILD_VERSION} CDP_TAG := -${CDP_BUILD_VERSION}
endif endif
@ -69,8 +72,8 @@ $(GENERATED_CRDS): $(GENERATED)
go tool controller-gen crd:crdVersions=v1,allowDangerousTypes=true paths=./pkg/apis/acid.zalan.do/... output:crd:dir=manifests go tool controller-gen crd:crdVersions=v1,allowDangerousTypes=true paths=./pkg/apis/acid.zalan.do/... output:crd:dir=manifests
@mv manifests/acid.zalan.do_postgresqls.yaml manifests/postgresql.crd.yaml @mv manifests/acid.zalan.do_postgresqls.yaml manifests/postgresql.crd.yaml
@# hack to use lowercase kind and listKind @# hack to use lowercase kind and listKind
@sed -i -e 's/kind: Postgresql/kind: postgresql/' manifests/postgresql.crd.yaml @$(SED) -i -e 's/kind: Postgresql/kind: postgresql/' manifests/postgresql.crd.yaml
@sed -i -e 's/listKind: PostgresqlList/listKind: postgresqlList/' manifests/postgresql.crd.yaml @$(SED) -i -e 's/listKind: PostgresqlList/listKind: postgresqlList/' manifests/postgresql.crd.yaml
@hack/adjust_postgresql_crd.sh @hack/adjust_postgresql_crd.sh
@mv manifests/acid.zalan.do_operatorconfigurations.yaml manifests/operatorconfiguration.crd.yaml @mv manifests/acid.zalan.do_operatorconfigurations.yaml manifests/operatorconfiguration.crd.yaml
@mv manifests/acid.zalan.do_postgresteams.yaml manifests/postgresteam.crd.yaml @mv manifests/acid.zalan.do_postgresteams.yaml manifests/postgresteam.crd.yaml

View File

@ -73,6 +73,8 @@ spec:
type: integer type: integer
gcp_credentials: gcp_credentials:
type: string type: string
irsa_role_arn:
type: string
kube_iam_role: kube_iam_role:
type: string type: string
log_s3_bucket: log_s3_bucket:

File diff suppressed because it is too large Load Diff

View File

@ -234,6 +234,7 @@ rules:
verbs: verbs:
- get - get
- create - create
- update
# to create role bindings to the postgres-pod service account # to create role bindings to the postgres-pod service account
- apiGroups: - apiGroups:
- rbac.authorization.k8s.io - rbac.authorization.k8s.io

View File

@ -50,7 +50,7 @@ configGeneral:
# ignore_resources_limits_annotation_key: "" # ignore_resources_limits_annotation_key: ""
# Select if setup uses endpoints (default), or configmaps to manage leader (DCS=k8s) # Select if setup uses endpoints (default), or configmaps to manage leader (DCS=k8s)
# kubernetes_use_configmaps: false kubernetes_use_configmaps: true
# maintenance windows applied to all Postgres clusters unless overridden in the manifest # maintenance windows applied to all Postgres clusters unless overridden in the manifest
# maintenance_windows: # maintenance_windows:
@ -360,6 +360,8 @@ configAwsOrGcp:
# AWS IAM role to supply in the iam.amazonaws.com/role annotation of Postgres pods # AWS IAM role to supply in the iam.amazonaws.com/role annotation of Postgres pods
# kube_iam_role: "" # kube_iam_role: ""
# Full ARN for IRSA (IAM Roles for Service Accounts) on EKS
# irsa_role_arn: ""
# S3 bucket to use for shipping postgres daily logs # S3 bucket to use for shipping postgres daily logs
# log_s3_bucket: "" # log_s3_bucket: ""

View File

@ -1094,6 +1094,32 @@ configuration:
wal_s3_bucket: your-backup-path wal_s3_bucket: your-backup-path
``` ```
Alternatively, if your cluster uses EKS with OIDC, you can use
[IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html)
(IAM Roles for Service Accounts) instead of kube2iam. Set `irsa_role_arn` to
the full ARN of the IAM role:
**OperatorConfiguration**
```yaml
apiVersion: "acid.zalan.do/v1"
kind: OperatorConfiguration
metadata:
name: postgresql-operator-configuration
configuration:
aws_or_gcp:
aws_region: eu-central-1
irsa_role_arn: arn:aws:iam::123456789012:role/postgres-pod-role
wal_s3_bucket: your-backup-path
```
When `irsa_role_arn` is set the operator annotates the pod service account with
`eks.amazonaws.com/role-arn` on every reconcile. The EKS OIDC webhook then
injects an AWS web identity token into each pod, which takes precedence over
the EC2 metadata credentials used by kube2iam. Both `kube_iam_role` and
`irsa_role_arn` can coexist during a migration — existing pods retain the
kube2iam annotation until they are rotated, at which point only IRSA is used.
The referenced IAM role should contain the following privileges to make sure The referenced IAM role should contain the following privileges to make sure
Postgres can send compressed WAL files to the given S3 bucket: Postgres can send compressed WAL files to the given S3 bucket:
@ -1204,6 +1230,7 @@ aws_or_gcp:
# additional_secret_mount_path: "" # additional_secret_mount_path: ""
# aws_region: eu-central-1 # aws_region: eu-central-1
# kube_iam_role: "" # kube_iam_role: ""
# irsa_role_arn: ""
# log_s3_bucket: "" # log_s3_bucket: ""
# wal_s3_bucket: "" # wal_s3_bucket: ""
wal_gs_bucket: "postgres-backups-bucket-28302F2" # name of bucket on where to save the WAL-E logs wal_gs_bucket: "postgres-backups-bucket-28302F2" # name of bucket on where to save the WAL-E logs
@ -1253,6 +1280,7 @@ aws_or_gcp:
additional_secret_mount_path: "/var/secrets/google" # or where ever you want to mount the file additional_secret_mount_path: "/var/secrets/google" # or where ever you want to mount the file
# aws_region: eu-central-1 # aws_region: eu-central-1
# kube_iam_role: "" # kube_iam_role: ""
# irsa_role_arn: ""
# log_s3_bucket: "" # log_s3_bucket: ""
# wal_s3_bucket: "" # wal_s3_bucket: ""
wal_gs_bucket: "postgres-backups-bucket-28302F2" # name of bucket on where to save the WAL-E logs wal_gs_bucket: "postgres-backups-bucket-28302F2" # name of bucket on where to save the WAL-E logs

29
docs/migrate.md Normal file
View File

@ -0,0 +1,29 @@
<h1>Migrate from v1 to v2</h1>
Version 2.0 changes some default settings and removes deprecated fields. Please read the following sections before upgrading the Postgres Operator deployment.
## K8s Endpoints are deprecated
If your current operator v1.x deployment is relying on K8s endpoints (the default setup) for Patroni to manage the HA state you have to start planning to switch to configmaps, because endpoints are deprecated from K8s 1.33 onwards. The default of the corresponding parameter `kubernetes_use_configmaps` is changing to `true` with v2.0 of the operator. This means you have to explicity set it to `false` in your configuration before you start the upgrade.
We explicitly warn you to go straight to configmap-based HA management with database clusters that use replicas, because there's is a danger to run into split-brain scenarios during the rolling update of pods when there exists a leader endpoint and leader config map at the same time. To play it safe, here is what you should do - before or after the Postgres Operator upgrade:
1. Scale-in all your database clusters to only one primary instance. This can be done by changing the global config options `max_instances` and `min_instances` to `1`. If you have allowed users to ignore globally defined instance limits by configuring an `ignore_instance_limits_annotation_key`, remove it for now.
2. Wait for all clusters to be healthy and change the `kubernetes_use_configmaps` setting to `true`. This will trigger the replacement of the primary pod of all clusters and cause downtime for as long as the pods are rescheduled and start up.
3. Check again that all clusters are healthy with configmaps created. There should be three for each cluster called like cluster name with suffixes `-config`, `-failover` and `-leader`. Now, revert the changes from step 1 and scale-out the to number of instances set in the manifests.
4. The orphaned endpoints, which use the same names like the new configmaps, have to be deleted by you or your K8s garbage collection.
## Dropped manifest fields
We removed some deprecated fields from the Postgresql CRD. Please, make sure that you do not specify them in any of your cluster manifests. If you do, switch to the listed alternative:
| Removed field in v2 | Alternative |
| --- | --- |
| init_containers | initContainers |
| pod_priority_class_name | podPriorityClassName |
| replicaLoadBalancer | enableReplicaLoadBalancer|
| useLoadBalancer | enableMasterLoadBalancer |

View File

@ -42,14 +42,14 @@ and change it.
To test the CRD-based configuration locally, use the following To test the CRD-based configuration locally, use the following
```bash ```
kubectl create -f manifests/operatorconfiguration.crd.yaml # registers the CRD kubectl create -f manifests/operatorconfiguration.crd.yaml # registers the CRD
kubectl create -f manifests/postgresql-operator-default-configuration.yaml kubectl create -f manifests/postgresql-operator-default-configuration.yaml
kubectl create -f manifests/operator-service-account-rbac.yaml kubectl create -f manifests/operator-service-account-rbac.yaml
kubectl create -f manifests/postgres-operator.yaml # set the env var as mentioned above kubectl create -f manifests/postgres-operator.yaml # set the env var as mentioned above
kubectl get operatorconfigurations postgresql-operator-default-configuration -o yaml kubectl get operatorconfigurations postgresql-operator-default-configuration -o yaml
``` ```
The CRD-based configuration is more powerful than the one based on ConfigMaps The CRD-based configuration is more powerful than the one based on ConfigMaps
@ -100,15 +100,13 @@ Those are top-level keys, containing both leaf keys and groups.
Kubernetes-native DCS). Kubernetes-native DCS).
* **kubernetes_use_configmaps** * **kubernetes_use_configmaps**
Select if setup uses endpoints (default), or configmaps to manage leader when Select if setup uses endpoints or configmaps (default) to manage leader when
DCS is kubernetes (not etcd or similar). In OpenShift it is not possible to DCS is kubernetes (not etcd or similar). In OpenShift it is not possible to
use endpoints option, and configmaps is required. Starting with K8s 1.33, use endpoints option, and configmaps is required. Starting with K8s 1.33,
endpoints are marked as deprecated. It's recommended to switch to config maps endpoints are marked as deprecated. It's recommended to switch to config maps
instead. But, to do so make sure you scale the Postgres cluster down to just instead. But, to do so make sure you scale the Postgres cluster down to just
one primary pod (e.g. using `max_instances` option). Otherwise, you risk one primary pod (e.g. using `max_instances` option). Otherwise, you risk
running into a split-brain scenario. running into a split-brain scenario. Default is `true`.
By default, `kubernetes_use_configmaps: false`, meaning endpoints will be used.
Starting from v1.16.0 the default will be changed to `true`.
* **docker_image** * **docker_image**
Spilo Docker image for Postgres instances. For production, don't rely on the Spilo Docker image for Postgres instances. For production, don't rely on the
@ -800,6 +798,15 @@ yet officially supported.
[kube2iam](https://github.com/jtblin/kube2iam) project on AWS. The default is [kube2iam](https://github.com/jtblin/kube2iam) project on AWS. The default is
empty. empty.
* **irsa_role_arn**
Full AWS IAM role ARN to supply in the `eks.amazonaws.com/role-arn` annotation
of the Postgres pod service account, enabling
[IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html)
(IAM Roles for Service Accounts) on EKS. When set, the operator annotates the
pod service account on every sync so that the EKS OIDC webhook can inject AWS
credentials directly into pods. Must be a full ARN, e.g.
`arn:aws:iam::123456789012:role/my-postgres-role`. The default is empty.
* **aws_region** * **aws_region**
AWS region used to store EBS volumes. The default is `eu-central-1`. Note, AWS region used to store EBS volumes. The default is `eu-central-1`. Note,
this option is not meant for specifying the AWS region for backups and this option is not meant for specifying the AWS region for backups and

View File

@ -1430,7 +1430,7 @@ class EndToEndTestCase(unittest.TestCase):
k8s.api.custom_objects_api.patch_namespaced_custom_object( k8s.api.custom_objects_api.patch_namespaced_custom_object(
"acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", pg_patch_resources) "acid.zalan.do", "v1", "default", "postgresqls", "acid-minimal-cluster", pg_patch_resources)
self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"}, self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"},
"Operator does not get in sync") "Operator does not get in sync", retries=120)
# wait for switched over # wait for switched over
k8s.wait_for_pod_failover(replica_nodes, 'spilo-role=master,' + cluster_label) k8s.wait_for_pod_failover(replica_nodes, 'spilo-role=master,' + cluster_label)

View File

@ -13,12 +13,14 @@
file="${1:-"manifests/postgresql.crd.yaml"}" file="${1:-"manifests/postgresql.crd.yaml"}"
sed -i '/^[[:space:]]*standby:$/{ SED=$(command -v gsed 2>/dev/null || command -v sed)
$SED -i '/^[[:space:]]*standby:$/{
# Capture the indentation # Capture the indentation
s/^\([[:space:]]*\)standby:$/\1standby:\n\1 anyOf:\n\1 - required:\n\1 - s3_wal_path\n\1 - required:\n\1 - gs_wal_path\n\1 - required:\n\1 - standby_host\n\1 not:\n\1 required:\n\1 - s3_wal_path\n\1 - gs_wal_path/ s/^\([[:space:]]*\)standby:$/\1standby:\n\1 anyOf:\n\1 - required:\n\1 - s3_wal_path\n\1 - required:\n\1 - gs_wal_path\n\1 - required:\n\1 - standby_host\n\1 not:\n\1 required:\n\1 - s3_wal_path\n\1 - gs_wal_path/
}' "$file" }' "$file"
sed -i '/^[[:space:]]*maintenanceWindows:$/{ $SED -i '/^[[:space:]]*maintenanceWindows:$/{
# Capture the indentation # Capture the indentation
s/^\([[:space:]]*\)maintenanceWindows:$/\1maintenanceWindows:\n\1 items:\n\1 pattern: '\''^\\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))-((2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))\\ *$'\''\n\1 type: string/ s/^\([[:space:]]*\)maintenanceWindows:$/\1maintenanceWindows:\n\1 items:\n\1 pattern: '\''^\\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))-((2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))\\ *$'\''\n\1 type: string/
}' "$file" }' "$file"

View File

@ -79,6 +79,7 @@ data:
# inherited_annotations: owned-by # inherited_annotations: owned-by
# inherited_labels: application,environment # inherited_labels: application,environment
# kube_iam_role: "" # kube_iam_role: ""
# irsa_role_arn: ""
kubernetes_use_configmaps: "false" kubernetes_use_configmaps: "false"
# log_s3_bucket: "" # log_s3_bucket: ""
# logical_backup_azure_storage_account_name: "" # logical_backup_azure_storage_account_name: ""

View File

@ -73,6 +73,8 @@ spec:
type: integer type: integer
gcp_credentials: gcp_credentials:
type: string type: string
irsa_role_arn:
type: string
kube_iam_role: kube_iam_role:
type: string type: string
log_s3_bucket: log_s3_bucket:

View File

@ -16,7 +16,7 @@ configuration:
etcd_host: "" etcd_host: ""
# ignore_instance_limits_annotation_key: "" # ignore_instance_limits_annotation_key: ""
# ignore_resources_limits_annotation_key: "" # ignore_resources_limits_annotation_key: ""
# kubernetes_use_configmaps: false kubernetes_use_configmaps: true
# maintenance_windows: # maintenance_windows:
# - "Sat:22:00-23:59" # - "Sat:22:00-23:59"
# - "Sun:00:00-01:00" # - "Sun:00:00-01:00"
@ -171,6 +171,7 @@ configuration:
# enable_ebs_gp3_migration_max_size: 1000 # enable_ebs_gp3_migration_max_size: 1000
# gcp_credentials: "" # gcp_credentials: ""
# kube_iam_role: "" # kube_iam_role: ""
# irsa_role_arn: ""
# log_s3_bucket: "" # log_s3_bucket: ""
# wal_az_storage_account: "" # wal_az_storage_account: ""
# wal_gs_bucket: "" # wal_gs_bucket: ""

File diff suppressed because it is too large Load Diff

View File

@ -252,6 +252,7 @@ type AWSGCPConfiguration struct {
WALAZStorageAccount string `json:"wal_az_storage_account,omitempty"` WALAZStorageAccount string `json:"wal_az_storage_account,omitempty"`
LogS3Bucket string `json:"log_s3_bucket,omitempty"` LogS3Bucket string `json:"log_s3_bucket,omitempty"`
KubeIAMRole string `json:"kube_iam_role,omitempty"` KubeIAMRole string `json:"kube_iam_role,omitempty"`
IRSARoleARN string `json:"irsa_role_arn,omitempty"`
AdditionalSecretMount string `json:"additional_secret_mount,omitempty"` AdditionalSecretMount string `json:"additional_secret_mount,omitempty"`
AdditionalSecretMountPath string `json:"additional_secret_mount_path,omitempty"` AdditionalSecretMountPath string `json:"additional_secret_mount_path,omitempty"`
EnableEBSGp3Migration bool `json:"enable_ebs_gp3_migration,omitempty"` EnableEBSGp3Migration bool `json:"enable_ebs_gp3_migration,omitempty"`
@ -419,7 +420,7 @@ type OperatorConfigurationData struct {
// +kubebuilder:default="" // +kubebuilder:default=""
EtcdHost string `json:"etcd_host,omitempty"` EtcdHost string `json:"etcd_host,omitempty"`
// +kubebuilder:default=true // +kubebuilder:default=true
KubernetesUseConfigMaps bool `json:"kubernetes_use_configmaps,omitempty"` KubernetesUseConfigMaps *bool `json:"kubernetes_use_configmaps,omitempty"`
// +kubebuilder:default="ghcr.io/zalando/spilo-18:4.1-p1" // +kubebuilder:default="ghcr.io/zalando/spilo-18:4.1-p1"
DockerImage string `json:"docker_image,omitempty"` DockerImage string `json:"docker_image,omitempty"`
// +kubebuilder:validation:Minimum=1 // +kubebuilder:validation:Minimum=1

View File

@ -73,6 +73,8 @@ spec:
type: integer type: integer
gcp_credentials: gcp_credentials:
type: string type: string
irsa_role_arn:
type: string
kube_iam_role: kube_iam_role:
type: string type: string
log_s3_bucket: log_s3_bucket:

File diff suppressed because it is too large Load Diff

View File

@ -76,12 +76,6 @@ type PostgresSpec struct {
EnableReplicaPoolerNodePort *bool `json:"enableReplicaPoolerNodePort,omitempty"` EnableReplicaPoolerNodePort *bool `json:"enableReplicaPoolerNodePort,omitempty"`
ReplicaPoolerNodePort *int32 `json:"replicaPoolerNodePort,omitempty"` ReplicaPoolerNodePort *int32 `json:"replicaPoolerNodePort,omitempty"`
// deprecated load balancer settings maintained for backward compatibility
// see "Load balancers" operator docs
UseLoadBalancer *bool `json:"useLoadBalancer,omitempty"`
// deprecated
ReplicaLoadBalancer *bool `json:"replicaLoadBalancer,omitempty"`
// load balancers' source ranges are the same for master and replica services // load balancers' source ranges are the same for master and replica services
// +nullable // +nullable
// +kubebuilder:validation:items:Pattern=`^((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\/(\d|[1-2]\d|3[0-2])|(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9]))$` // +kubebuilder:validation:items:Pattern=`^((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\/(\d|[1-2]\d|3[0-2])|(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9]))$`
@ -130,11 +124,6 @@ type PostgresSpec struct {
AdditionalVolumes []AdditionalVolume `json:"additionalVolumes,omitempty"` AdditionalVolumes []AdditionalVolume `json:"additionalVolumes,omitempty"`
Streams []Stream `json:"streams,omitempty"` Streams []Stream `json:"streams,omitempty"`
Env []v1.EnvVar `json:"env,omitempty"` Env []v1.EnvVar `json:"env,omitempty"`
// deprecated
InitContainersOld []v1.Container `json:"init_containers,omitempty"`
// deprecated
PodPriorityClassNameOld string `json:"pod_priority_class_name,omitempty"`
} }
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

View File

@ -9,7 +9,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/zalando/postgres-operator/pkg/util"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
) )
@ -178,7 +177,8 @@ var unmarshalCluster = []struct {
"metadata": {"name": "acid-testcluster1"}, "spec": {"teamId": 100}}`), &tmp).Error(), "metadata": {"name": "acid-testcluster1"}, "spec": {"teamId": 100}}`), &tmp).Error(),
}, },
marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0,"slots":null},"teamId":"","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"clone":null},"status":"Invalid"}`), marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0,"slots":null},"teamId":"","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"clone":null},"status":"Invalid"}`),
err: nil}, err: nil,
},
{ {
about: "example with /status subresource", about: "example with /status subresource",
in: []byte(`{ in: []byte(`{
@ -199,156 +199,8 @@ var unmarshalCluster = []struct {
"metadata": {"name": "acid-testcluster1"}, "spec": {"teamId": 100}}`), &tmp).Error(), "metadata": {"name": "acid-testcluster1"}, "spec": {"teamId": 100}}`), &tmp).Error(),
}, },
marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0,"slots":null},"teamId":"","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"clone":null},"status":{"PostgresClusterStatus":"Invalid"}}`), marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0,"slots":null},"teamId":"","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"clone":null},"status":{"PostgresClusterStatus":"Invalid"}}`),
err: nil}, err: nil,
{ },
about: "example with detailed input manifest and deprecated pod_priority_class_name -> podPriorityClassName",
in: []byte(`{
"kind": "Postgresql",
"apiVersion": "acid.zalan.do/v1",
"metadata": {
"name": "acid-testcluster1"
},
"spec": {
"teamId": "acid",
"pod_priority_class_name": "spilo-pod-priority",
"volume": {
"size": "5Gi",
"storageClass": "SSD",
"subPath": "subdir"
},
"numberOfInstances": 2,
"users": {
"zalando": [
"superuser",
"createdb"
]
},
"allowedSourceRanges": [
"127.0.0.1/32"
],
"postgresql": {
"version": "18",
"parameters": {
"shared_buffers": "32MB",
"max_connections": "10",
"log_statement": "all"
}
},
"resources": {
"requests": {
"cpu": "10m",
"memory": "50Mi"
},
"limits": {
"cpu": "300m",
"memory": "3000Mi"
}
},
"clone" : {
"cluster": "acid-batman"
},
"enableShmVolume": false,
"patroni": {
"initdb": {
"encoding": "UTF8",
"locale": "en_US.UTF-8",
"data-checksums": "true"
},
"pg_hba": [
"hostssl all all 0.0.0.0/0 md5",
"host all all 0.0.0.0/0 md5"
],
"ttl": 30,
"loop_wait": 10,
"retry_timeout": 10,
"maximum_lag_on_failover": 33554432,
"slots" : {
"permanent_logical_1" : {
"type" : "logical",
"database" : "foo",
"plugin" : "pgoutput"
}
}
},
"maintenanceWindows": [
"Mon:01:00-06:00",
"Sat:00:00-04:00",
"05:00-05:15"
]
}
}`),
out: Postgresql{
TypeMeta: metav1.TypeMeta{
Kind: "Postgresql",
APIVersion: "acid.zalan.do/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: "acid-testcluster1",
},
Spec: PostgresSpec{
PostgresqlParam: PostgresqlParam{
PgVersion: "18",
Parameters: map[string]string{
"shared_buffers": "32MB",
"max_connections": "10",
"log_statement": "all",
},
},
PodPriorityClassNameOld: "spilo-pod-priority",
Volume: Volume{
Size: "5Gi",
StorageClass: "SSD",
SubPath: "subdir",
},
ShmVolume: util.False(),
Patroni: Patroni{
InitDB: map[string]string{
"encoding": "UTF8",
"locale": "en_US.UTF-8",
"data-checksums": "true",
},
PgHba: []string{"hostssl all all 0.0.0.0/0 md5", "host all all 0.0.0.0/0 md5"},
TTL: 30,
LoopWait: 10,
RetryTimeout: 10,
MaximumLagOnFailover: 33554432,
Slots: map[string]map[string]string{"permanent_logical_1": {"type": "logical", "database": "foo", "plugin": "pgoutput"}},
},
Resources: &Resources{
ResourceRequests: ResourceDescription{CPU: stringToPointer("10m"), Memory: stringToPointer("50Mi")},
ResourceLimits: ResourceDescription{CPU: stringToPointer("300m"), Memory: stringToPointer("3000Mi")},
},
TeamID: "acid",
AllowedSourceRanges: []string{"127.0.0.1/32"},
NumberOfInstances: 2,
Users: map[string]UserFlags{"zalando": {"superuser", "createdb"}},
MaintenanceWindows: []MaintenanceWindow{{
Everyday: false,
Weekday: time.Monday,
StartTime: mustParseTime("01:00"),
EndTime: mustParseTime("06:00"),
}, {
Everyday: false,
Weekday: time.Saturday,
StartTime: mustParseTime("00:00"),
EndTime: mustParseTime("04:00"),
},
{
Everyday: true,
Weekday: time.Sunday,
StartTime: mustParseTime("05:00"),
EndTime: mustParseTime("05:15"),
},
},
Clone: &CloneDescription{
ClusterName: "acid-batman",
},
},
Error: "",
},
marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"18","parameters":{"log_statement":"all","max_connections":"10","shared_buffers":"32MB"}},"pod_priority_class_name":"spilo-pod-priority","volume":{"size":"5Gi","storageClass":"SSD", "subPath": "subdir"},"enableShmVolume":false,"patroni":{"initdb":{"data-checksums":"true","encoding":"UTF8","locale":"en_US.UTF-8"},"pg_hba":["hostssl all all 0.0.0.0/0 md5","host all all 0.0.0.0/0 md5"],"ttl":30,"loop_wait":10,"retry_timeout":10,"maximum_lag_on_failover":33554432,"slots":{"permanent_logical_1":{"database":"foo","plugin":"pgoutput","type":"logical"}}},"resources":{"requests":{"cpu":"10m","memory":"50Mi"},"limits":{"cpu":"300m","memory":"3000Mi"}},"teamId":"acid","allowedSourceRanges":["127.0.0.1/32"],"numberOfInstances":2,"users":{"zalando":["superuser","createdb"]},"maintenanceWindows":["Mon:01:00-06:00","Sat:00:00-04:00","05:00-05:15"],"clone":{"cluster":"acid-batman"}},"status":{"PostgresClusterStatus":""}}`),
err: nil},
{ {
about: "example with clone", about: "example with clone",
in: []byte(`{"kind": "Postgresql","apiVersion": "acid.zalan.do/v1","metadata": {"name": "acid-testcluster1"}, "spec": {"teamId": "acid", "clone": {"cluster": "team-batman"}}}`), in: []byte(`{"kind": "Postgresql","apiVersion": "acid.zalan.do/v1","metadata": {"name": "acid-testcluster1"}, "spec": {"teamId": "acid", "clone": {"cluster": "team-batman"}}}`),
@ -369,7 +221,8 @@ var unmarshalCluster = []struct {
Error: "", Error: "",
}, },
marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0,"slots":null},"teamId":"acid","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"clone":{"cluster":"team-batman"}},"status":{"PostgresClusterStatus":""}}`), marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0,"slots":null},"teamId":"acid","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"clone":{"cluster":"team-batman"}},"status":{"PostgresClusterStatus":""}}`),
err: nil}, err: nil,
},
{ {
about: "standby example", about: "standby example",
in: []byte(`{"kind": "Postgresql","apiVersion": "acid.zalan.do/v1","metadata": {"name": "acid-testcluster1"}, "spec": {"teamId": "acid", "standby": {"s3_wal_path": "s3://custom/path/to/bucket/"}}}`), in: []byte(`{"kind": "Postgresql","apiVersion": "acid.zalan.do/v1","metadata": {"name": "acid-testcluster1"}, "spec": {"teamId": "acid", "standby": {"s3_wal_path": "s3://custom/path/to/bucket/"}}}`),
@ -390,7 +243,8 @@ var unmarshalCluster = []struct {
Error: "", Error: "",
}, },
marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0,"slots":null},"teamId":"acid","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"standby":{"s3_wal_path":"s3://custom/path/to/bucket/"}},"status":{"PostgresClusterStatus":""}}`), marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0,"slots":null},"teamId":"acid","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"standby":{"s3_wal_path":"s3://custom/path/to/bucket/"}},"status":{"PostgresClusterStatus":""}}`),
err: nil}, err: nil,
},
{ {
about: "expect error on malformatted JSON", about: "expect error on malformatted JSON",
in: []byte(`{"kind": "Postgresql","apiVersion": "acid.zalan.do/v1"`), in: []byte(`{"kind": "Postgresql","apiVersion": "acid.zalan.do/v1"`),

View File

@ -458,6 +458,11 @@ func (in *OperatorConfigurationData) DeepCopyInto(out *OperatorConfigurationData
*out = make([]string, len(*in)) *out = make([]string, len(*in))
copy(*out, *in) copy(*out, *in)
} }
if in.KubernetesUseConfigMaps != nil {
in, out := &in.KubernetesUseConfigMaps, &out.KubernetesUseConfigMaps
*out = new(bool)
**out = **in
}
if in.ResyncPeriod != nil { if in.ResyncPeriod != nil {
in, out := &in.ResyncPeriod, &out.ResyncPeriod in, out := &in.ResyncPeriod, &out.ResyncPeriod
*out = new(metav1.Duration) *out = new(metav1.Duration)
@ -858,16 +863,6 @@ func (in *PostgresSpec) DeepCopyInto(out *PostgresSpec) {
*out = new(int32) *out = new(int32)
**out = **in **out = **in
} }
if in.UseLoadBalancer != nil {
in, out := &in.UseLoadBalancer, &out.UseLoadBalancer
*out = new(bool)
**out = **in
}
if in.ReplicaLoadBalancer != nil {
in, out := &in.ReplicaLoadBalancer, &out.ReplicaLoadBalancer
*out = new(bool)
**out = **in
}
if in.AllowedSourceRanges != nil { if in.AllowedSourceRanges != nil {
in, out := &in.AllowedSourceRanges, &out.AllowedSourceRanges in, out := &in.AllowedSourceRanges, &out.AllowedSourceRanges
*out = make([]string, len(*in)) *out = make([]string, len(*in))
@ -1036,13 +1031,6 @@ func (in *PostgresSpec) DeepCopyInto(out *PostgresSpec) {
(*in)[i].DeepCopyInto(&(*out)[i]) (*in)[i].DeepCopyInto(&(*out)[i])
} }
} }
if in.InitContainersOld != nil {
in, out := &in.InitContainersOld, &out.InitContainersOld
*out = make([]corev1.Container, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return return
} }

View File

@ -1084,7 +1084,7 @@ func (c *Cluster) Update(oldSpec, newSpec *acidv1.Postgresql) error {
// Patroni service and endpoints / config maps // Patroni service and endpoints / config maps
if err := c.syncPatroniResources(); err != nil { if err := c.syncPatroniResources(); err != nil {
c.logger.Errorf("could not sync services: %v", err) c.logger.Errorf("could not sync Patroni resources: %v", err)
updateFailed = true updateFailed = true
} }
@ -1140,6 +1140,12 @@ func (c *Cluster) Update(oldSpec, newSpec *acidv1.Postgresql) error {
c.logger.Infof("Storage resize is disabled (storage_resize_mode is off). Skipping volume size sync.") c.logger.Infof("Storage resize is disabled (storage_resize_mode is off). Skipping volume size sync.")
} }
// Pod service account (IRSA annotation sync)
if err := c.syncPodServiceAccount(); err != nil {
c.logger.Errorf("could not sync pod service account: %v", err)
updateFailed = true
}
// Statefulset // Statefulset
func() { func() {
if err := c.syncStatefulSet(); err != nil { if err := c.syncStatefulSet(); err != nil {

View File

@ -95,6 +95,7 @@ func TestCreate(t *testing.T) {
client := k8sutil.KubernetesClient{ client := k8sutil.KubernetesClient{
DeploymentsGetter: clientSet.AppsV1(), DeploymentsGetter: clientSet.AppsV1(),
ConfigMapsGetter: clientSet.CoreV1(),
CronJobsGetter: clientSet.BatchV1(), CronJobsGetter: clientSet.BatchV1(),
EndpointsGetter: clientSet.CoreV1(), EndpointsGetter: clientSet.CoreV1(),
PersistentVolumeClaimsGetter: clientSet.CoreV1(), PersistentVolumeClaimsGetter: clientSet.CoreV1(),

View File

@ -1340,28 +1340,6 @@ func (c *Cluster) generateStatefulSet(spec *acidv1.PostgresSpec) (*appsv1.Statef
} }
} }
// backward compatible check for InitContainers
if spec.InitContainersOld != nil {
msg := "manifest parameter init_containers is deprecated."
if spec.InitContainers == nil {
c.logger.Warningf("%s Consider using initContainers instead.", msg)
spec.InitContainers = spec.InitContainersOld
} else {
c.logger.Warningf("%s Only value from initContainers is used", msg)
}
}
// backward compatible check for PodPriorityClassName
if spec.PodPriorityClassNameOld != "" {
msg := "manifest parameter pod_priority_class_name is deprecated."
if spec.PodPriorityClassName == "" {
c.logger.Warningf("%s Consider using podPriorityClassName instead.", msg)
spec.PodPriorityClassName = spec.PodPriorityClassNameOld
} else {
c.logger.Warningf("%s Only value from podPriorityClassName is used", msg)
}
}
spiloConfiguration, err := generateSpiloJSONConfiguration(&spec.PostgresqlParam, &spec.Patroni, &c.OpConfig, c.logger) spiloConfiguration, err := generateSpiloJSONConfiguration(&spec.PostgresqlParam, &spec.Patroni, &c.OpConfig, c.logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not generate Spilo JSON configuration: %v", err) return nil, fmt.Errorf("could not generate Spilo JSON configuration: %v", err)
@ -2300,6 +2278,12 @@ func (c *Cluster) generatePrimaryPodDisruptionBudget() *policyv1.PodDisruptionBu
labels[c.OpConfig.PodRoleLabel] = string(Master) labels[c.OpConfig.PodRoleLabel] = string(Master)
} }
// When master selector is disabled and synchronous_mode_strict is on, require
// master + synchronous_node_count (default 1) healthy pods for write quorum.
if pdbMasterLabelSelector != nil && !*pdbMasterLabelSelector && minAvailable.IntVal > 0 && c.Spec.SynchronousModeStrict {
minAvailable = intstr.FromInt32(int32(c.Spec.SynchronousNodeCount + 1))
}
return &policyv1.PodDisruptionBudget{ return &policyv1.PodDisruptionBudget{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: c.PrimaryPodDisruptionBudgetName(), Name: c.PrimaryPodDisruptionBudgetName(),

View File

@ -582,60 +582,60 @@ func TestGenerateSpiloPodEnvVars(t *testing.T) {
} }
expectedValuesS3Bucket := []ExpectedValue{ expectedValuesS3Bucket := []ExpectedValue{
{ {
envIndex: 15, envIndex: 16,
envVarConstant: "WAL_S3_BUCKET", envVarConstant: "WAL_S3_BUCKET",
envVarValue: "global-s3-bucket", envVarValue: "global-s3-bucket",
}, },
{ {
envIndex: 16, envIndex: 17,
envVarConstant: "WAL_BUCKET_SCOPE_SUFFIX", envVarConstant: "WAL_BUCKET_SCOPE_SUFFIX",
envVarValue: fmt.Sprintf("/%s", dummyUUID), envVarValue: fmt.Sprintf("/%s", dummyUUID),
}, },
{ {
envIndex: 17, envIndex: 18,
envVarConstant: "WAL_BUCKET_SCOPE_PREFIX", envVarConstant: "WAL_BUCKET_SCOPE_PREFIX",
envVarValue: "", envVarValue: "",
}, },
} }
expectedValuesGCPCreds := []ExpectedValue{ expectedValuesGCPCreds := []ExpectedValue{
{ {
envIndex: 15, envIndex: 16,
envVarConstant: "WAL_GS_BUCKET", envVarConstant: "WAL_GS_BUCKET",
envVarValue: "global-gs-bucket", envVarValue: "global-gs-bucket",
}, },
{ {
envIndex: 16, envIndex: 17,
envVarConstant: "WAL_BUCKET_SCOPE_SUFFIX", envVarConstant: "WAL_BUCKET_SCOPE_SUFFIX",
envVarValue: fmt.Sprintf("/%s", dummyUUID), envVarValue: fmt.Sprintf("/%s", dummyUUID),
}, },
{ {
envIndex: 17, envIndex: 18,
envVarConstant: "WAL_BUCKET_SCOPE_PREFIX", envVarConstant: "WAL_BUCKET_SCOPE_PREFIX",
envVarValue: "", envVarValue: "",
}, },
{ {
envIndex: 18, envIndex: 19,
envVarConstant: "GOOGLE_APPLICATION_CREDENTIALS", envVarConstant: "GOOGLE_APPLICATION_CREDENTIALS",
envVarValue: "some-path-to-credentials", envVarValue: "some-path-to-credentials",
}, },
} }
expectedS3BucketConfigMap := []ExpectedValue{ expectedS3BucketConfigMap := []ExpectedValue{
{ {
envIndex: 17, envIndex: 18,
envVarConstant: "wal_s3_bucket", envVarConstant: "wal_s3_bucket",
envVarValue: "global-s3-bucket-configmap", envVarValue: "global-s3-bucket-configmap",
}, },
} }
expectedCustomS3BucketSpec := []ExpectedValue{ expectedCustomS3BucketSpec := []ExpectedValue{
{ {
envIndex: 15, envIndex: 16,
envVarConstant: "WAL_S3_BUCKET", envVarConstant: "WAL_S3_BUCKET",
envVarValue: "custom-s3-bucket", envVarValue: "custom-s3-bucket",
}, },
} }
expectedCustomVariableSecret := []ExpectedValue{ expectedCustomVariableSecret := []ExpectedValue{
{ {
envIndex: 16, envIndex: 17,
envVarConstant: "custom_variable", envVarConstant: "custom_variable",
envVarValueRef: &v1.EnvVarSource{ envVarValueRef: &v1.EnvVarSource{
SecretKeyRef: &v1.SecretKeySelector{ SecretKeyRef: &v1.SecretKeySelector{
@ -649,72 +649,72 @@ func TestGenerateSpiloPodEnvVars(t *testing.T) {
} }
expectedCustomVariableConfigMap := []ExpectedValue{ expectedCustomVariableConfigMap := []ExpectedValue{
{ {
envIndex: 16, envIndex: 17,
envVarConstant: "custom_variable", envVarConstant: "custom_variable",
envVarValue: "configmap-test", envVarValue: "configmap-test",
}, },
} }
expectedCustomVariableSpec := []ExpectedValue{ expectedCustomVariableSpec := []ExpectedValue{
{ {
envIndex: 15, envIndex: 16,
envVarConstant: "CUSTOM_VARIABLE", envVarConstant: "CUSTOM_VARIABLE",
envVarValue: "spec-env-test", envVarValue: "spec-env-test",
}, },
} }
expectedCloneEnvSpec := []ExpectedValue{ expectedCloneEnvSpec := []ExpectedValue{
{ {
envIndex: 16, envIndex: 17,
envVarConstant: "CLONE_WALE_S3_PREFIX", envVarConstant: "CLONE_WALE_S3_PREFIX",
envVarValue: "s3://another-bucket", envVarValue: "s3://another-bucket",
}, },
{ {
envIndex: 19, envIndex: 20,
envVarConstant: "CLONE_WAL_BUCKET_SCOPE_PREFIX", envVarConstant: "CLONE_WAL_BUCKET_SCOPE_PREFIX",
envVarValue: "", envVarValue: "",
}, },
{ {
envIndex: 20, envIndex: 21,
envVarConstant: "CLONE_AWS_ENDPOINT", envVarConstant: "CLONE_AWS_ENDPOINT",
envVarValue: "s3.eu-central-1.amazonaws.com", envVarValue: "s3.eu-central-1.amazonaws.com",
}, },
} }
expectedCloneEnvSpecEnv := []ExpectedValue{ expectedCloneEnvSpecEnv := []ExpectedValue{
{ {
envIndex: 15, envIndex: 16,
envVarConstant: "CLONE_WAL_BUCKET_SCOPE_PREFIX", envVarConstant: "CLONE_WAL_BUCKET_SCOPE_PREFIX",
envVarValue: "test-cluster", envVarValue: "test-cluster",
}, },
{ {
envIndex: 17, envIndex: 18,
envVarConstant: "CLONE_WALE_S3_PREFIX", envVarConstant: "CLONE_WALE_S3_PREFIX",
envVarValue: "s3://another-bucket", envVarValue: "s3://another-bucket",
}, },
{ {
envIndex: 21, envIndex: 22,
envVarConstant: "CLONE_AWS_ENDPOINT", envVarConstant: "CLONE_AWS_ENDPOINT",
envVarValue: "s3.eu-central-1.amazonaws.com", envVarValue: "s3.eu-central-1.amazonaws.com",
}, },
} }
expectedCloneEnvConfigMap := []ExpectedValue{ expectedCloneEnvConfigMap := []ExpectedValue{
{ {
envIndex: 16, envIndex: 17,
envVarConstant: "CLONE_WAL_S3_BUCKET", envVarConstant: "CLONE_WAL_S3_BUCKET",
envVarValue: "global-s3-bucket", envVarValue: "global-s3-bucket",
}, },
{ {
envIndex: 17, envIndex: 18,
envVarConstant: "CLONE_WAL_BUCKET_SCOPE_SUFFIX", envVarConstant: "CLONE_WAL_BUCKET_SCOPE_SUFFIX",
envVarValue: fmt.Sprintf("/%s", dummyUUID), envVarValue: fmt.Sprintf("/%s", dummyUUID),
}, },
{ {
envIndex: 21, envIndex: 22,
envVarConstant: "clone_aws_endpoint", envVarConstant: "clone_aws_endpoint",
envVarValue: "s3.eu-west-1.amazonaws.com", envVarValue: "s3.eu-west-1.amazonaws.com",
}, },
} }
expectedCloneEnvSecret := []ExpectedValue{ expectedCloneEnvSecret := []ExpectedValue{
{ {
envIndex: 21, envIndex: 22,
envVarConstant: "clone_aws_access_key_id", envVarConstant: "clone_aws_access_key_id",
envVarValueRef: &v1.EnvVarSource{ envVarValueRef: &v1.EnvVarSource{
SecretKeyRef: &v1.SecretKeySelector{ SecretKeyRef: &v1.SecretKeySelector{
@ -728,12 +728,12 @@ func TestGenerateSpiloPodEnvVars(t *testing.T) {
} }
expectedStandbyEnvSecret := []ExpectedValue{ expectedStandbyEnvSecret := []ExpectedValue{
{ {
envIndex: 15, envIndex: 16,
envVarConstant: "STANDBY_WALE_GS_PREFIX", envVarConstant: "STANDBY_WALE_GS_PREFIX",
envVarValue: "gs://some/path/", envVarValue: "gs://some/path/",
}, },
{ {
envIndex: 20, envIndex: 21,
envVarConstant: "standby_google_application_credentials", envVarConstant: "standby_google_application_credentials",
envVarValueRef: &v1.EnvVarSource{ envVarValueRef: &v1.EnvVarSource{
SecretKeyRef: &v1.SecretKeySelector{ SecretKeyRef: &v1.SecretKeySelector{
@ -2691,13 +2691,13 @@ func TestGeneratePodDisruptionBudget(t *testing.T) {
k8sutil.KubernetesClient{}, k8sutil.KubernetesClient{},
acidv1.Postgresql{ acidv1.Postgresql{
ObjectMeta: metav1.ObjectMeta{Name: "myapp-database", Namespace: "myapp"}, ObjectMeta: metav1.ObjectMeta{Name: "myapp-database", Namespace: "myapp"},
Spec: acidv1.PostgresSpec{TeamID: "myapp", NumberOfInstances: 3}}, Spec: acidv1.PostgresSpec{TeamID: "myapp", NumberOfInstances: 3, Patroni: acidv1.Patroni{SynchronousModeStrict: true, SynchronousNodeCount: 1}}},
logger, logger,
eventRecorder), eventRecorder),
check: []func(cluster *Cluster, podDisruptionBudget *policyv1.PodDisruptionBudget) error{ check: []func(cluster *Cluster, podDisruptionBudget *policyv1.PodDisruptionBudget) error{
testPodDisruptionBudgetOwnerReference, testPodDisruptionBudgetOwnerReference,
hasName("postgres-myapp-database-pdb"), hasName("postgres-myapp-database-pdb"),
hasMinAvailable(1), hasMinAvailable(2),
testLabelsAndSelectors(true), testLabelsAndSelectors(true),
}, },
}, },
@ -2976,6 +2976,7 @@ func getServices(serviceType v1.ServiceType, sourceRanges []string, extTrafficPo
ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyType(extTrafficPolicy), ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyType(extTrafficPolicy),
LoadBalancerSourceRanges: sourceRanges, LoadBalancerSourceRanges: sourceRanges,
Ports: []v1.ServicePort{{Name: "postgresql", Port: 5432, TargetPort: intstr.IntOrString{IntVal: 5432}, NodePort: nodePort}}, Ports: []v1.ServicePort{{Name: "postgresql", Port: 5432, TargetPort: intstr.IntOrString{IntVal: 5432}, NodePort: nodePort}},
Selector: map[string]string{"spilo-role": "master", "application": "spilo", "cluster-name": clusterName},
Type: serviceType, Type: serviceType,
}, },
{ {

View File

@ -67,6 +67,10 @@ func (c *Cluster) Sync(newSpec *acidv1.Postgresql) error {
} }
}() }()
if !c.patroniKubernetesUseConfigMaps() {
c.logger.Warning("K8s endpoints are deprecated. Please, enable kubernetes_use_configmaps. Requires scale-in to a single primary, see v1 -> v2 migration docs!")
}
if err = c.syncFinalizer(); err != nil { if err = c.syncFinalizer(); err != nil {
c.logger.Debugf("could not sync finalizers: %v", err) c.logger.Debugf("could not sync finalizers: %v", err)
} }
@ -103,6 +107,10 @@ func (c *Cluster) Sync(newSpec *acidv1.Postgresql) error {
} }
} }
if err = c.syncPodServiceAccount(); err != nil {
c.logger.Errorf("could not sync pod service account: %v", err)
}
if err = c.syncStatefulSet(); err != nil { if err = c.syncStatefulSet(); err != nil {
if !k8sutil.ResourceAlreadyExists(err) { if !k8sutil.ResourceAlreadyExists(err) {
err = fmt.Errorf("could not sync statefulsets: %v", err) err = fmt.Errorf("could not sync statefulsets: %v", err)
@ -626,6 +634,10 @@ func (c *Cluster) syncStatefulSet() error {
if !cmp.rollingUpdate { if !cmp.rollingUpdate {
updatedPodAnnotations := map[string]*string{} updatedPodAnnotations := map[string]*string{}
for _, anno := range cmp.deletedPodAnnotations { for _, anno := range cmp.deletedPodAnnotations {
// during IRSA migration let kube2iam annotation drain naturally via pod rotation
if c.OpConfig.IRSARoleARN != "" && anno == constants.KubeIAmAnnotation {
continue
}
updatedPodAnnotations[anno] = nil updatedPodAnnotations[anno] = nil
} }
for anno, val := range desiredSts.Spec.Template.Annotations { for anno, val := range desiredSts.Spec.Template.Annotations {
@ -1799,6 +1811,7 @@ func (c *Cluster) syncLogicalBackupJob() error {
// no existing logical backup job, create new one // no existing logical backup job, create new one
c.logger.Info("could not find the cluster's logical backup job") c.logger.Info("could not find the cluster's logical backup job")
if err = c.createLogicalBackupJob(); err == nil { if err = c.createLogicalBackupJob(); err == nil {
c.logger.Infof("created missing logical backup job %s", jobName) c.logger.Infof("created missing logical backup job %s", jobName)
} else { } else {
@ -1813,3 +1826,62 @@ func (c *Cluster) syncLogicalBackupJob() error {
return nil return nil
} }
func (c *Cluster) syncPodServiceAccount() error {
sa, err := c.KubeClient.ServiceAccounts(c.Namespace).Get(context.TODO(), c.OpConfig.PodServiceAccountName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("could not get pod service account %q: %v", c.OpConfig.PodServiceAccountName, err)
}
changed := false
if c.OpConfig.IRSARoleARN != "" {
if val, ok := sa.Annotations[constants.IRSAAnnotation]; !ok || val != c.OpConfig.IRSARoleARN {
if sa.Annotations == nil {
sa.Annotations = make(map[string]string)
}
sa.Annotations[constants.IRSAAnnotation] = c.OpConfig.IRSARoleARN
changed = true
}
} else {
if _, ok := sa.Annotations[constants.IRSAAnnotation]; ok {
delete(sa.Annotations, constants.IRSAAnnotation)
changed = true
}
}
if changed {
if _, err = c.KubeClient.ServiceAccounts(c.Namespace).Update(context.TODO(), sa, metav1.UpdateOptions{}); err != nil {
return fmt.Errorf("could not update pod service account %q: %v", sa.Name, err)
}
c.logger.Infof("synced annotations on pod service account %q", sa.Name)
}
if c.OpConfig.IRSARoleARN != "" {
c.logIRSAMigrationProgress()
}
return nil
}
func (c *Cluster) logIRSAMigrationProgress() {
pods, err := c.listPods()
if err != nil {
c.logger.Warnf("IRSA migration: could not list pods: %v", err)
return
}
total := len(pods)
remaining := 0
for _, pod := range pods {
if _, ok := pod.Annotations[constants.KubeIAmAnnotation]; ok {
remaining++
}
}
if remaining > 0 {
c.logger.Infof("IRSA migration in progress: %d/%d pods still carry kube2iam annotation, will be removed on next rotation", remaining, total)
} else {
c.logger.Infof("IRSA migration complete: all %d pods have rotated, kube2iam annotation fully drained", total)
}
}

View File

@ -627,9 +627,12 @@ func (c *Cluster) patroniKubernetesUseConfigMaps() bool {
if !c.patroniUsesKubernetes() { if !c.patroniUsesKubernetes() {
return false return false
} }
if c.OpConfig.KubernetesUseConfigMaps == nil {
return true
}
// otherwise, follow the operator configuration // otherwise, follow the operator configuration
return c.OpConfig.KubernetesUseConfigMaps return *c.OpConfig.KubernetesUseConfigMaps
} }
// Earlier arguments take priority // Earlier arguments take priority

View File

@ -53,6 +53,7 @@ func newFakeK8sAnnotationsClient() (k8sutil.KubernetesClient, *k8sFake.Clientset
EndpointsGetter: clientSet.CoreV1(), EndpointsGetter: clientSet.CoreV1(),
ConfigMapsGetter: clientSet.CoreV1(), ConfigMapsGetter: clientSet.CoreV1(),
PodsGetter: clientSet.CoreV1(), PodsGetter: clientSet.CoreV1(),
ServiceAccountsGetter: clientSet.CoreV1(),
DeploymentsGetter: clientSet.AppsV1(), DeploymentsGetter: clientSet.AppsV1(),
CronJobsGetter: clientSet.BatchV1(), CronJobsGetter: clientSet.BatchV1(),
}, clientSet }, clientSet
@ -299,7 +300,7 @@ func newInheritedAnnotationsCluster(client k8sutil.KubernetesClient) (*Cluster,
OpConfig: config.Config{ OpConfig: config.Config{
PatroniAPICheckInterval: &metav1.Duration{Duration: 1 * time.Second}, PatroniAPICheckInterval: &metav1.Duration{Duration: 1 * time.Second},
PatroniAPICheckTimeout: &metav1.Duration{Duration: 5 * time.Second}, PatroniAPICheckTimeout: &metav1.Duration{Duration: 5 * time.Second},
KubernetesUseConfigMaps: true, KubernetesUseConfigMaps: util.True(),
ConnectionPooler: config.ConnectionPooler{ ConnectionPooler: config.ConnectionPooler{
ConnectionPoolerDefaultCPURequest: "100m", ConnectionPoolerDefaultCPURequest: "100m",
ConnectionPoolerDefaultCPULimit: "100m", ConnectionPoolerDefaultCPULimit: "100m",
@ -388,7 +389,7 @@ func createPatroniResources(cluster *Cluster) error {
Labels: cluster.labelsSet(false), Labels: cluster.labelsSet(false),
} }
if cluster.OpConfig.KubernetesUseConfigMaps { if cluster.OpConfig.KubernetesUseConfigMaps != nil && *cluster.OpConfig.KubernetesUseConfigMaps {
configMap := v1.ConfigMap{ configMap := v1.ConfigMap{
ObjectMeta: metadata, ObjectMeta: metadata,
} }
@ -598,7 +599,7 @@ func TestInheritedAnnotations(t *testing.T) {
// 3. Change from ConfigMaps to Endpoints // 3. Change from ConfigMaps to Endpoints
err = cluster.deletePatroniResources() err = cluster.deletePatroniResources()
assert.NoError(t, err) assert.NoError(t, err)
cluster.OpConfig.KubernetesUseConfigMaps = false cluster.OpConfig.KubernetesUseConfigMaps = util.False()
err = createPatroniResources(cluster) err = createPatroniResources(cluster)
assert.NoError(t, err) assert.NoError(t, err)
err = cluster.Sync(newSpec.DeepCopy()) err = cluster.Sync(newSpec.DeepCopy())

View File

@ -247,6 +247,12 @@ func (c *Controller) initPodServiceAccount() {
c.PodServiceAccount.Name = c.opConfig.PodServiceAccountName c.PodServiceAccount.Name = c.opConfig.PodServiceAccountName
} }
c.PodServiceAccount.Namespace = "" c.PodServiceAccount.Namespace = ""
if c.opConfig.IRSARoleARN != "" {
if c.PodServiceAccount.Annotations == nil {
c.PodServiceAccount.Annotations = make(map[string]string)
}
c.PodServiceAccount.Annotations[constants.IRSAAnnotation] = c.opConfig.IRSARoleARN
}
} }
// actual service accounts are deployed at the time of Postgres/Spilo cluster creation // actual service accounts are deployed at the time of Postgres/Spilo cluster creation

View File

@ -35,7 +35,7 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur
result.EnableSpiloWalPathCompat = fromCRD.EnableSpiloWalPathCompat result.EnableSpiloWalPathCompat = fromCRD.EnableSpiloWalPathCompat
result.EnableTeamIdClusternamePrefix = fromCRD.EnableTeamIdClusternamePrefix result.EnableTeamIdClusternamePrefix = fromCRD.EnableTeamIdClusternamePrefix
result.EtcdHost = fromCRD.EtcdHost result.EtcdHost = fromCRD.EtcdHost
result.KubernetesUseConfigMaps = fromCRD.KubernetesUseConfigMaps result.KubernetesUseConfigMaps = util.CoalesceBool(fromCRD.KubernetesUseConfigMaps, util.True())
result.DockerImage = util.Coalesce(fromCRD.DockerImage, "ghcr.io/zalando/spilo-18:4.1-p1") result.DockerImage = util.Coalesce(fromCRD.DockerImage, "ghcr.io/zalando/spilo-18:4.1-p1")
result.Workers = util.CoalesceUInt32(fromCRD.Workers, 8) result.Workers = util.CoalesceUInt32(fromCRD.Workers, 8)
result.MinInstances = fromCRD.MinInstances result.MinInstances = fromCRD.MinInstances
@ -175,6 +175,7 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur
result.AWSRegion = fromCRD.AWSGCP.AWSRegion result.AWSRegion = fromCRD.AWSGCP.AWSRegion
result.LogS3Bucket = fromCRD.AWSGCP.LogS3Bucket result.LogS3Bucket = fromCRD.AWSGCP.LogS3Bucket
result.KubeIAMRole = fromCRD.AWSGCP.KubeIAMRole result.KubeIAMRole = fromCRD.AWSGCP.KubeIAMRole
result.IRSARoleARN = fromCRD.AWSGCP.IRSARoleARN
result.WALGSBucket = fromCRD.AWSGCP.WALGSBucket result.WALGSBucket = fromCRD.AWSGCP.WALGSBucket
result.GCPCredentials = fromCRD.AWSGCP.GCPCredentials result.GCPCredentials = fromCRD.AWSGCP.GCPCredentials
result.WALAZStorageAccount = fromCRD.AWSGCP.WALAZStorageAccount result.WALAZStorageAccount = fromCRD.AWSGCP.WALAZStorageAccount

View File

@ -213,22 +213,10 @@ func (c *Controller) processEvent(event ClusterEvent, isInInitialList bool) {
} }
lg.Debugf("observed cluster status %s, running sync scan to repair the cluster", lastOperationStatus) lg.Debugf("observed cluster status %s, running sync scan to repair the cluster", lastOperationStatus)
event.EventType = EventSync event.EventType = EventSync
} } else if event.EventType != EventDelete {
if event.EventType == EventAdd || event.EventType == EventUpdate || event.EventType == EventSync {
// handle deprecated parameters by possibly assigning their values to the new ones.
if event.OldSpec != nil {
c.mergeDeprecatedPostgreSQLSpecParameters(&event.OldSpec.Spec)
}
if event.NewSpec != nil {
c.warnOnDeprecatedPostgreSQLSpecParameters(&event.NewSpec.Spec)
c.mergeDeprecatedPostgreSQLSpecParameters(&event.NewSpec.Spec)
}
if err = c.submitRBACCredentials(event); err != nil { if err = c.submitRBACCredentials(event); err != nil {
c.logger.Warnf("pods and/or Patroni may misfunction due to the lack of permissions: %v", err) c.logger.Warnf("pods and/or Patroni may misfunction due to the lack of permissions: %v", err)
} }
} }
switch event.EventType { switch event.EventType {
@ -397,45 +385,6 @@ func (c *Controller) processClusterEventsQueue(idx int, stopCh <-chan struct{},
} }
} }
func (c *Controller) warnOnDeprecatedPostgreSQLSpecParameters(spec *acidv1.PostgresSpec) {
deprecate := func(deprecated, replacement string) {
c.logger.Warningf("parameter %q is deprecated. Consider setting %q instead", deprecated, replacement)
}
if spec.UseLoadBalancer != nil {
deprecate("useLoadBalancer", "enableMasterLoadBalancer")
}
if spec.ReplicaLoadBalancer != nil {
deprecate("replicaLoadBalancer", "enableReplicaLoadBalancer")
}
if (spec.UseLoadBalancer != nil || spec.ReplicaLoadBalancer != nil) &&
(spec.EnableReplicaLoadBalancer != nil || spec.EnableMasterLoadBalancer != nil) {
c.logger.Warnf("both old and new load balancer parameters are present in the manifest, ignoring old ones")
}
}
// mergeDeprecatedPostgreSQLSpecParameters modifies the spec passed to the cluster by setting current parameter
// values from the obsolete ones. Note: while the spec that is modified is a copy made in queueClusterEvent, it is
// still a shallow copy, so be extra careful not to modify values pointer fields point to, but copy them instead.
func (c *Controller) mergeDeprecatedPostgreSQLSpecParameters(spec *acidv1.PostgresSpec) *acidv1.PostgresSpec {
if (spec.UseLoadBalancer != nil || spec.ReplicaLoadBalancer != nil) &&
(spec.EnableReplicaLoadBalancer == nil && spec.EnableMasterLoadBalancer == nil) {
if spec.UseLoadBalancer != nil {
spec.EnableMasterLoadBalancer = new(bool)
*spec.EnableMasterLoadBalancer = *spec.UseLoadBalancer
}
if spec.ReplicaLoadBalancer != nil {
spec.EnableReplicaLoadBalancer = new(bool)
*spec.EnableReplicaLoadBalancer = *spec.ReplicaLoadBalancer
}
}
spec.ReplicaLoadBalancer = nil
spec.UseLoadBalancer = nil
return spec
}
func (c *Controller) queueClusterEvent(informerOldSpec, informerNewSpec *acidv1.Postgresql, eventType EventType) { func (c *Controller) queueClusterEvent(informerOldSpec, informerNewSpec *acidv1.Postgresql, eventType EventType) {
var ( var (
uid types.UID uid types.UID

View File

@ -64,35 +64,6 @@ func TestControllerOwnershipOnPostgresql(t *testing.T) {
} }
} }
func TestMergeDeprecatedPostgreSQLSpecParameters(t *testing.T) {
tests := []struct {
name string
in *acidv1.PostgresSpec
out *acidv1.PostgresSpec
error string
}{
{
"Check that old parameters propagate values to the new ones",
&acidv1.PostgresSpec{UseLoadBalancer: &True, ReplicaLoadBalancer: &True},
&acidv1.PostgresSpec{UseLoadBalancer: nil, ReplicaLoadBalancer: nil,
EnableMasterLoadBalancer: &True, EnableReplicaLoadBalancer: &True},
"New parameters should be set from the values of old ones",
},
{
"Check that new parameters are not set when both old and new ones are present",
&acidv1.PostgresSpec{UseLoadBalancer: &True, EnableMasterLoadBalancer: &False},
&acidv1.PostgresSpec{UseLoadBalancer: nil, EnableMasterLoadBalancer: &False},
"New parameters should remain unchanged when both old and new are present",
},
}
for _, tt := range tests {
result := postgresqlTestController.mergeDeprecatedPostgreSQLSpecParameters(tt.in)
if !reflect.DeepEqual(result, tt.out) {
t.Errorf("%s: %v", tt.name, tt.error)
}
}
}
func TestMeetsClusterDeleteAnnotations(t *testing.T) { func TestMeetsClusterDeleteAnnotations(t *testing.T) {
// set delete annotations in configuration // set delete annotations in configuration
postgresqlTestController.opConfig.DeleteAnnotationDateKey = "delete-date" postgresqlTestController.opConfig.DeleteAnnotationDateKey = "delete-date"

View File

@ -183,7 +183,7 @@ type Config struct {
ConnectionPooler ConnectionPooler
WatchedNamespace string `name:"watched_namespace"` // special values: "*" means 'watch all namespaces', the empty string "" means 'watch a namespace where operator is deployed to' WatchedNamespace string `name:"watched_namespace"` // special values: "*" means 'watch all namespaces', the empty string "" means 'watch a namespace where operator is deployed to'
KubernetesUseConfigMaps bool `name:"kubernetes_use_configmaps" default:"false"` KubernetesUseConfigMaps *bool `name:"kubernetes_use_configmaps" default:"true"`
EtcdHost string `name:"etcd_host" default:""` // special values: the empty string "" means Patroni will use K8s as a DCS EtcdHost string `name:"etcd_host" default:""` // special values: the empty string "" means Patroni will use K8s as a DCS
EnableMaintenanceWindows *bool `name:"enable_maintenance_windows" default:"true"` EnableMaintenanceWindows *bool `name:"enable_maintenance_windows" default:"true"`
MaintenanceWindows []string `name:"maintenance_windows"` MaintenanceWindows []string `name:"maintenance_windows"`
@ -200,6 +200,7 @@ type Config struct {
WALES3Bucket string `name:"wal_s3_bucket"` WALES3Bucket string `name:"wal_s3_bucket"`
LogS3Bucket string `name:"log_s3_bucket"` LogS3Bucket string `name:"log_s3_bucket"`
KubeIAMRole string `name:"kube_iam_role"` KubeIAMRole string `name:"kube_iam_role"`
IRSARoleARN string `name:"irsa_role_arn"`
WALGSBucket string `name:"wal_gs_bucket"` WALGSBucket string `name:"wal_gs_bucket"`
GCPCredentials string `name:"gcp_credentials"` GCPCredentials string `name:"gcp_credentials"`
WALAZStorageAccount string `name:"wal_az_storage_account"` WALAZStorageAccount string `name:"wal_az_storage_account"`

View File

@ -4,6 +4,7 @@ package constants
const ( const (
ZalandoDNSNameAnnotation = "external-dns.alpha.kubernetes.io/hostname" ZalandoDNSNameAnnotation = "external-dns.alpha.kubernetes.io/hostname"
KubeIAmAnnotation = "iam.amazonaws.com/role" KubeIAmAnnotation = "iam.amazonaws.com/role"
IRSAAnnotation = "eks.amazonaws.com/role-arn"
VolumeStorateProvisionerAnnotation = "pv.kubernetes.io/provisioned-by" VolumeStorateProvisionerAnnotation = "pv.kubernetes.io/provisioned-by"
PostgresqlControllerAnnotationKey = "acid.zalan.do/controller" PostgresqlControllerAnnotationKey = "acid.zalan.do/controller"
) )