diff --git a/docs/administrator.md b/docs/administrator.md index d7cda8e8b..fe36e3744 100644 --- a/docs/administrator.md +++ b/docs/administrator.md @@ -1346,10 +1346,12 @@ If you are using [additional environment variables](#custom-pod-environment-vari to access your backup location you have to copy those variables and prepend the `STANDBY_` prefix for Spilo to find the backups and WAL files to stream. -Alternatively, standby clusters can also stream from a remote primary cluster. +Standby clusters can also stream from a remote primary cluster. You have to specify the host address. Port is optional and defaults to 5432. -Note, that only one of the options (`s3_wal_path`, `gs_wal_path`, -`standby_host`) can be present under the `standby` top-level key. +You can combine `standby_host` with either `s3_wal_path` or `gs_wal_path` +for additional redundancy. Note that `s3_wal_path` and `gs_wal_path` are +mutually exclusive. At least one of `s3_wal_path`, `gs_wal_path`, or +`standby_host` must be specified under the `standby` top-level key. ## Logical backups diff --git a/docs/reference/cluster_manifest.md b/docs/reference/cluster_manifest.md index ab0353202..aab3b818f 100644 --- a/docs/reference/cluster_manifest.md +++ b/docs/reference/cluster_manifest.md @@ -457,17 +457,21 @@ under the `clone` top-level key and do not affect the already running cluster. On startup, an existing `standby` top-level key creates a standby Postgres cluster streaming from a remote location - either from a S3 or GCS WAL -archive or a remote primary. Only one of options is allowed and required -if the `standby` key is present. +archive, a remote primary, or a combination of both. At least one of +`s3_wal_path`, `gs_wal_path`, or `standby_host` must be specified. +Note that `s3_wal_path` and `gs_wal_path` are mutually exclusive. * **s3_wal_path** the url to S3 bucket containing the WAL archive of the remote primary. + Can be combined with `standby_host` for additional redundancy. * **gs_wal_path** the url to GS bucket containing the WAL archive of the remote primary. + Can be combined with `standby_host` for additional redundancy. * **standby_host** hostname or IP address of the primary to stream from. + Can be specified alone or combined with either `s3_wal_path` or `gs_wal_path`. * **standby_port** TCP port on which the primary is listening for connections. Patroni will diff --git a/docs/user.md b/docs/user.md index c1a7c7d45..db33d0bd6 100644 --- a/docs/user.md +++ b/docs/user.md @@ -900,8 +900,9 @@ 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. You can stream changes from archived WAL files (AWS S3 or Google Cloud -Storage) or from a remote primary. Only one option can be specified in the -manifest: +Storage), from a remote primary, or combine a remote primary with a WAL archive. +At least one of `s3_wal_path`, `gs_wal_path`, or `standby_host` must be specified. +Note that `s3_wal_path` and `gs_wal_path` are mutually exclusive. ```yaml spec: @@ -929,6 +930,16 @@ spec: standby_port: "5433" ``` +You can also combine a remote primary with a WAL archive for additional redundancy: + +```yaml +spec: + standby: + standby_host: "acid-minimal-cluster.default" + standby_port: "5433" + s3_wal_path: "s3:///spilo///wal/" +``` + Note, that the pods and services use the same role labels like for normal clusters: The standby leader is labeled as `master`. When using the `standby_host` option you have to copy the credentials from the source cluster's secrets to successfully diff --git a/hack/adjust_postgresql_crd.sh b/hack/adjust_postgresql_crd.sh index cceb33f64..d06b74a2d 100755 --- a/hack/adjust_postgresql_crd.sh +++ b/hack/adjust_postgresql_crd.sh @@ -5,15 +5,17 @@ # # Injections: # -# * oneOf: for the standby field to enforce that only one of s3_wal_path, gs_wal_path or standby_host is set. -# * This can later be done with // +kubebuilder:validation:ExactlyOneOf marker, but this requires latest Kubernetes version. (Currently the operator depends on v1.32.9) +# * oneOf: for the standby field to enforce validation rules: +# - s3_wal_path and gs_wal_path are mutually exclusive +# - standby_host can be specified alone or with either s3_wal_path OR gs_wal_path +# - at least one of s3_wal_path, gs_wal_path, or standby_host must be set # * type: string and pattern for the maintenanceWindows items. file="${1:-"manifests/postgresql.crd.yaml"}" sed -i '/^[[:space:]]*standby:$/{ # Capture the indentation - s/^\([[:space:]]*\)standby:$/\1standby:\n\1 oneOf:\n\1 - required:\n\1 - s3_wal_path\n\1 - required:\n\1 - gs_wal_path\n\1 - required:\n\1 - standby_host/ + 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" sed -i '/^[[:space:]]*maintenanceWindows:$/{ diff --git a/manifests/postgresql.crd.yaml b/manifests/postgresql.crd.yaml index c16d3cf14..db8efabe6 100644 --- a/manifests/postgresql.crd.yaml +++ b/manifests/postgresql.crd.yaml @@ -3924,15 +3924,22 @@ spec: format: int64 type: integer standby: - oneOf: + anyOf: - required: - s3_wal_path - required: - gs_wal_path - required: - standby_host - description: StandbyDescription contains remote primary config or - s3/gs wal path + not: + required: + - s3_wal_path + - gs_wal_path + description: StandbyDescription contains remote primary config and/or + s3/gs wal path. standby_host can be specified alone or together with + either s3_wal_path OR gs_wal_path (mutually exclusive). At least + one field must be specified. s3_wal_path and gs_wal_path are mutually + exclusive. properties: gs_wal_path: type: string diff --git a/manifests/standby-manifest.yaml b/manifests/standby-manifest.yaml index eb90464a6..b06956a1b 100644 --- a/manifests/standby-manifest.yaml +++ b/manifests/standby-manifest.yaml @@ -9,7 +9,9 @@ spec: numberOfInstances: 1 postgresql: version: "17" - # Make this a standby cluster and provide either the s3 bucket path of source cluster or the remote primary host for continuous streaming. + # Make this a standby cluster. You can specify s3_wal_path or gs_wal_path for WAL archive, + # standby_host for remote primary streaming, or combine standby_host with either WAL path. + # Note: s3_wal_path and gs_wal_path are mutually exclusive. standby: # s3_wal_path: "s3://mybucket/spilo/acid-minimal-cluster/abcd1234-2a4b-4b2a-8c9c-c1234defg567/wal/14/" standby_host: "acid-minimal-cluster.default" diff --git a/pkg/apis/acid.zalan.do/v1/postgresql_type.go b/pkg/apis/acid.zalan.do/v1/postgresql_type.go index 17fecd7a1..cffaf6fd6 100644 --- a/pkg/apis/acid.zalan.do/v1/postgresql_type.go +++ b/pkg/apis/acid.zalan.do/v1/postgresql_type.go @@ -246,8 +246,9 @@ type Patroni struct { FailsafeMode *bool `json:"failsafe_mode,omitempty"` } -// StandbyDescription contains remote primary config or s3/gs wal path -// +kubebuilder:validation:ExactlyOneOf=s3_wal_path;gs_wal_path;standby_host +// StandbyDescription contains remote primary config and/or s3/gs wal path. +// standby_host can be specified alone or together with either s3_wal_path OR gs_wal_path (mutually exclusive). +// At least one field must be specified. s3_wal_path and gs_wal_path are mutually exclusive. type StandbyDescription struct { S3WalPath string `json:"s3_wal_path,omitempty"` GSWalPath string `json:"gs_wal_path,omitempty"` diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 7c267fcee..638f752e4 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -2207,25 +2207,29 @@ func (c *Cluster) generateStandbyEnvironment(description *acidv1.StandbyDescript Value: description.StandbyPort, }) } - } else { - c.logger.Info("standby cluster streaming from WAL location") - if description.S3WalPath != "" { - result = append(result, v1.EnvVar{ - Name: "STANDBY_WALE_S3_PREFIX", - Value: description.S3WalPath, - }) - } else if description.GSWalPath != "" { - result = append(result, v1.EnvVar{ - Name: "STANDBY_WALE_GS_PREFIX", - Value: description.GSWalPath, - }) - } else { - c.logger.Error("no WAL path specified in standby section") - return result - } + } + // WAL archive can be specified with or without standby_host + if description.S3WalPath != "" { + c.logger.Info("standby cluster using S3 WAL archive") + result = append(result, v1.EnvVar{ + Name: "STANDBY_WALE_S3_PREFIX", + Value: description.S3WalPath, + }) result = append(result, v1.EnvVar{Name: "STANDBY_METHOD", Value: "STANDBY_WITH_WALE"}) result = append(result, v1.EnvVar{Name: "STANDBY_WAL_BUCKET_SCOPE_PREFIX", Value: ""}) + } else if description.GSWalPath != "" { + c.logger.Info("standby cluster using GCS WAL archive") + result = append(result, v1.EnvVar{ + Name: "STANDBY_WALE_GS_PREFIX", + Value: description.GSWalPath, + }) + result = append(result, v1.EnvVar{Name: "STANDBY_METHOD", Value: "STANDBY_WITH_WALE"}) + result = append(result, v1.EnvVar{Name: "STANDBY_WAL_BUCKET_SCOPE_PREFIX", Value: ""}) + } else if description.StandbyHost == "" { + // Neither WAL path nor standby_host specified + c.logger.Error("no WAL path or standby_host specified in standby section") + return result } return result diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 43b4725e4..8674a25c8 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -1370,7 +1370,20 @@ func TestStandbyEnv(t *testing.T) { envLen: 2, }, { - subTest: "from remote primary - ignore WAL path", + subTest: "from remote primary with S3 WAL path", + standbyOpts: &acidv1.StandbyDescription{ + S3WalPath: "s3://some/path/", + StandbyHost: "remote-primary", + }, + env: v1.EnvVar{ + Name: "STANDBY_HOST", + Value: "remote-primary", + }, + envPos: 0, + envLen: 4, + }, + { + subTest: "from remote primary with GCS WAL path", standbyOpts: &acidv1.StandbyDescription{ GSWalPath: "gs://some/path/", StandbyHost: "remote-primary", @@ -1380,7 +1393,7 @@ func TestStandbyEnv(t *testing.T) { Value: "remote-primary", }, envPos: 0, - envLen: 1, + envLen: 4, }, }