Skip to content

K8SPG-1057: Allow using etcd as patroni DCS - #1647

Closed
yoav-katz wants to merge 50 commits into
percona:mainfrom
yoav-katz:etcd-dcs
Closed

K8SPG-1057: Allow using etcd as patroni DCS#1647
yoav-katz wants to merge 50 commits into
percona:mainfrom
yoav-katz:etcd-dcs

Conversation

@yoav-katz

@yoav-katz yoav-katz commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

CHANGE DESCRIPTION

Problem:
Patroni supports multiple DCS backends, but the operator hardcodes Kubernetes Endpoints as the only option. This blocks clusters on managed Kubernetes platforms where workloads cannot reach the control plane API.

Cause:
The kubernetes: stanza was hardcoded in the generated Patroni config with no mechanism to select a different backend.
Several other pieces of the operator also assumed k8s DCS: RBAC rules unconditionally granted Endpoints permissions, the primary service routed through Patroni-managed Endpoints objects, and pod role labels/annotations were expected to be set by Patroni itself (which only happens with k8s DCS).

Solution:
Add a spec.patroni.dcs field (type: kubernetes default, type: etcd alternative). The field is immutable after cluster creation, enforced by a CEL validation rule on the CRD.

When type: etcd, the operator:

  • Emits an etcd3: stanza in the generated Patroni config instead of kubernetes:, with optional TLS (cacert/cert/key) and auth credentials (PATRONI_ETCD3_USERNAME/PATRONI_ETCD3_PASSWORD) sourced from referenced Secrets.
  • Injects on_start and on_role_change Patroni callbacks pointing to a new patroni-role-change.sh script. Since Patroni does not set pod role labels or the status annotation when using etcd DCS, this script patches the pod via the k8s API on every role transition, restoring the label (role=primary|replica) and annotation ({"role":"primary"}) that the rest of the operator depends on for Service routing and primary detection.
  • Creates the primary Service with a label selector (role=primary) instead of the previous headless-Endpoints-to-Patroni-leader-ClusterIP indirection, which only works with k8s DCS.
  • Skips creating the Patroni leader lease Service and distributed configuration Service, which are k8s DCS artifacts.
  • Omits the Endpoints RBAC permissions from the postgres pod ServiceAccount, since they are not needed.
  • Validates that referenced TLS and auth Secrets exist and contain the required keys, surfacing issues as Warning events on the cluster.

Reconciliation model (event-driven → periodic):
With k8s DCS the operator is event-driven: Patroni writes leader election, bootstrap (initialize), and pending-restart state into Endpoints objects and pod annotations, and the operator's watches turn those writes into reconciles. Under etcd DCS none of those k8s writes happen, so those watch events never fire. The operator compensates in three ways:

  • Periodic requeue. Once the cluster is bootstrapped, reconcilePatroniStatus requeues every spec.patroni.syncPeriodSeconds (default 10s, the same value used for Patroni's loop_wait) so pending restarts are detected on a poll cadence rather than via a watch. config changes that require a restart (e.g. max_connections) are applied within roughly one sync period, not instantly.
  • Live REST reads instead of annotation reads. Restart detection (handlePatroniRestarts → PodRequiresRestart) and system-identifier discovery (reconcilePatroniStatus) query Patroni's monitoring REST API by calling into a running pod, instead of reading the status/initialize k8s annotations that don't exist under etcd.
  • Role transitions still trigger reconciles via the pod PATCH the role-change script performs - the label change is a watched event.

In-place restore DCS cleanup:
An in-place restore must clear stale Patroni state before re-bootstrapping. Under k8s DCS this is done by deleting the Patroni Endpoints objects. Those don't exist under etcd, so instead a one-shot Job runs patronictl remove against etcd to clear the leader lock, member keys, and initialize key. It runs only after Patroni is confirmed stopped (so no live process re-registers itself), and the operator gates the restore state machine on the Job (create → wait → retry-on-failure). The Job reuses the restore's affinity, tolerations, resources, and priorityClassName.

The Kubernetes DCS path is unchanged.

CHECKLIST

Jira

  • Is the Jira ticket created and referenced properly?
  • Does the Jira ticket have the proper statuses for documentation (Needs Doc) and QA (Needs QA)?
  • Does the Jira ticket link to the proper milestone (Fix Version field)?

Tests

  • Is an E2E test/test case added for the new feature/change?
  • Are unit tests added where appropriate?

Config/Logging/Testability

  • Are all needed new/changed options added to default YAML files?
  • Helm Chart Merge Request
  • Did we add proper logging messages for operator actions?
  • Did we ensure compatibility with the previous version or cluster upgrade process?
  • Does the change support oldest and newest supported PG version?
  • Does the change support oldest and newest supported Kubernetes version?

@it-percona-cla

it-percona-cla commented Jun 18, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@yoav-katz
yoav-katz marked this pull request as draft June 18, 2026 22:26
@yoav-katz

Copy link
Copy Markdown
Contributor Author

I will wait for the jira ticket to open the PR in the helm charts repo

@yoav-katz

Copy link
Copy Markdown
Contributor Author

and another note - this is my first OSS contribute so be gentel 😄
if there is stuff that you think should be changed becuase of style/dependency consideration I will be happy to fix!

@egegunes egegunes changed the title feat(etcd) K8SPG-1057: Allow using etcd as patroni DCS Jun 19, 2026

@egegunes egegunes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yoav-katz the implementation looks good to me in general. but we definitely need an e2e test that deploys etcd and configures PerconaPGCluster to use it.

@DanBrima

Copy link
Copy Markdown

would love to see this merged!

@yoav-katz

Copy link
Copy Markdown
Contributor Author

@egegunes Im not sure if the tests are failuing because a change I did.
More than that, what should I do with the failing lint?

@egegunes

Copy link
Copy Markdown
Contributor

@yoav-katz I reran the failing tests, let's wait for their results to see if they're broken.

For the linter, please add // nolint:gosec to hardcoded credentials errors. We can ignore the ones for Endpoints deprecation.

@yoav-katz
yoav-katz requested a review from mayankshah1607 July 29, 2026 14:48
@mayankshah1607

Copy link
Copy Markdown
Member

Hey @yoav-katz please resolve the conflicts and the linting errors

@yoav-katz

yoav-katz commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

@mayankshah1607 conflict resolved, thanks!
the lint failure - this is the known Endpoints deprecation warning (Kubernetes recommends EndpointSlice going forward), not something introduced by this PR. @egegunes confirmed it's acceptable to leave as-is for now; tracked separately in K8SPG-866.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 48 out of 49 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

internal/patroni/config.go:219

    1. Problem: The closing braces in this map literal are misindented, indicating the surrounding block wasn’t gofmt’d.
  1. Why it matters: Consistent formatting keeps the generated YAML logic easy to read/maintain and avoids CI formatting failures.
  2. Fix: Align the braces/indentation with gofmt output.
    pkg/apis/upstream.pgv2.percona.com/v1beta1/patroni_types.go:106
    1. Problem: The immutability CEL rule allows changing the DCS type when the existing object has no stored type field (e.g., clusters created before this field existed), because !has(oldSelf.type) short-circuits the check.
  1. Why it matters: This permits switching an existing cluster from the implicit Kubernetes DCS to etcd after creation, which contradicts the stated “immutable after cluster creation” contract and could lead to unsafe/unsupported transitions.
  2. Fix: Treat a missing oldSelf.type as the default (kubernetes) and only allow self.type to remain kubernetes in that case.
    internal/patroni/config.go:214
    1. Problem: This block has stray whitespace and incorrect indentation (spaces instead of tabs), so the file is not gofmt-formatted.
  1. Why it matters: The repository’s Go code is expected to be gofmt’d; leaving formatting drift makes reviews harder and can fail formatting/lint checks.
  2. Fix: Reformat this conditional with gofmt (remove the whitespace-only line and align braces/tabs).

This issue also appears on line 215 of the same file.
internal/controller/postgrescluster/patroni.go:78

    1. Problem: Under external DCS, errors querying Patroni’s REST API for pending_restart are silently swallowed (err == nil && restart).
  1. Why it matters: If the REST call fails (TLS/SAN mismatch, transient network issue, curl missing, etc.), the operator will skip required restarts without any log signal, making issues hard to diagnose.
  2. Fix: Log the error (and keep returning false) so failures are visible in operator logs.

@yoav-katz

Copy link
Copy Markdown
Contributor Author

fixed all problems co-pilot suggested:

  1. ran gofmt after pulling from main 😄
  2. fixed the bug in the CEL rule to block changing from default (empty) to etcd
  3. added logs for when the api to patroni fails

@JNKPercona

Copy link
Copy Markdown
Collaborator
Test Name Result Time
backup-enable-disable passed 00:14:09
builtin-extensions passed 00:06:42
custom-extensions passed 00:15:49
cert-manager-tls passed 00:11:14
custom-envs passed 00:18:32
custom-tls passed 00:06:21
database-init-sql passed 00:02:28
demand-backup passed 00:25:37
demand-backup-offline-snapshot passed 00:13:53
dynamic-configuration passed 00:04:10
finalizers passed 00:04:02
init-deploy passed 00:03:36
huge-pages passed 00:02:54
major-upgrade-14-to-15 passed 00:09:57
major-upgrade-15-to-16 passed 00:10:28
major-upgrade-16-to-17 passed 00:11:08
major-upgrade-17-to-18 passed 00:11:32
ldap passed 00:03:54
ldap-tls passed 00:07:52
monitoring passed 00:08:24
one-pod passed 00:05:48
operator-self-healing passed 00:11:15
pgbouncer-mtls passed 00:03:22
pg-tde passed 00:11:15
pitr passed 00:12:11
scaling passed 00:06:26
scheduled-backup passed 00:29:19
self-healing passed 00:09:14
sidecars passed 00:03:01
standby-pgbackrest passed 00:17:47
standby-streaming passed 00:12:46
start-from-backup passed 00:13:31
tablespaces passed 00:07:09
telemetry-transfer passed 00:04:44
upgrade-consistency passed 00:05:58
upgrade-minor passed 00:06:23
users passed 00:05:29
etcd-dcs passed 00:03:36
migration-from-crunchy-standby passed 00:18:49
migration-from-crunchy-pv passed 00:09:12
migration-from-crunchy-backup-restore passed 00:15:47
Summary Value
Tests Run 41/41
Job Duration 02:22:06
Total Test Time 06:46:04

commit: b46ab19
image: perconalab/percona-postgresql-operator:PR-1647-b46ab1921

@yoav-katz
yoav-katz requested a review from egegunes July 30, 2026 20:03
@mayankshah1607

mayankshah1607 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Hey @yoav-katz ! Firstly, thank you for all your work on this PR so far, and sorry for the delay with getting this reviewed. This is a big change that touches several parts of the codebase so we want to be careful that the design is right moving forward.

While discussing this internally, we wondered whether we could improve the code organization a bit. Most of the operator was originally written with Kubernetes as the only DCS backend in mind, so a lot of the current logic is tightly coupled to that assumption. As a result we now have this UsesExternalDCS() check in several places. I think this may become hard to maintain and test in the long run as we add more backends.

One possible direction would be to introduce an interface that abstracts the DCS, for example

type DCSBackend interface {
	GetClusterConfig(cluster *v1beta.PostgresCluster) (map[string]string, error)
	GetInstanceConfig(cluster *v1beta.PostgresCluster) (map[string]string, error)
	GetSystemIdentifier(cluster *v1beta.PostgresCluster) (string, error)
	CheckIsPendingRestart(cluster *v1beta.PostgresCluster) (bool, error)
	ConfigureInstancePodEnv(cluster *v1beta.PostgresCluster, podTpl *corev1.PodTemplateCheckIsPendingRestart) (bool, error)
	GetPermissions(cluster *v1beta.PostgresCluster) ([]rbacv1.PolicyRule, error)
         // .. not a complete list, maybe we need more methods
}

The idea here is that each backend implements this interface, the implementation is initialised once at the start of Reconcile, and each of the sub-reconcile functions would not need to know which backend is being used, and be left completely untouched.

What do you feel about such an approach? We are also open to other ideas or improve on top of this if you see a better way to structure it. 😄

@egegunes

Copy link
Copy Markdown
Contributor

@yoav-katz

We were discussing this PR internally and were concerned about the upcoming maintenance cost after merging this PR. What Mayank proposed above would make this much easier to reason about and maintain, also potentially allow adding more backends in the future.

This is a big change request, I understand if you're not willing to do it. If that's the case, we'll take this as a feature request and implement it in the upcoming releases.

If you're willing to do it, we'll assist you all the way of course, but I don't think this will make it into v3.1.0.

@egegunes egegunes removed this from the v3.1.0 milestone Jul 31, 2026
@yoav-katz

Copy link
Copy Markdown
Contributor Author

Ha, yeah -for what it's worth, I did consider a DCSBackend-style abstraction early on, and held off mainly to keep the diff reviewable. But I agree it's the right long-term shape, so I'll fold it into this PR rather than punt it.

@yoav-katz

Copy link
Copy Markdown
Contributor Author

@egegunes
no worries!
I do enjoy this stuff way too much to say no 😄 Count me in, I'll take it on.
Not fussed about missing v3.1.0 - happy to get the abstraction right over getting it in fast. I'll keep chipping away and ping this thread when I've got an updated diff (or a design doc first if that's easier to react to before I write 2000 lines of Go nobody asked for).

@egegunes

Copy link
Copy Markdown
Contributor

@egegunes

no worries!

I do enjoy this stuff way too much to say no 😄 Count me in, I'll take it on.

Not fussed about missing v3.1.0 - happy to get the abstraction right over getting it in fast. I'll keep chipping away and ping this thread when I've got an updated diff (or a design doc first if that's easier to react to before I write 2000 lines of Go nobody asked for).

@yoav-katz a design doc would be great to prevent back and forth and wasting your efforts

@yoav-katz

Copy link
Copy Markdown
Contributor Author

@mayankshah1607 @egegunes I would like your feedback about this implementation -

Proposal

Introduce a new package:

internal/patroni/dcs

This package becomes the single place that knows about specific DCS implementations.

It exposes a backend interface with two implementations:

internal/patroni/dcs/kubernetes.go
internal/patroni/dcs/etcd.go

The backend is selected once per reconcile:

backend := dcs.For(cluster)

After that, callers interact only with backend capabilities and do not know which DCS is active.

Examples of backend-owned behavior:

  • Patroni configuration additions

    • Kubernetes: kubernetes DCS section
    • etcd: etcd3 DCS section and related callbacks
  • Pod additions

    • Kubernetes: PATRONI_KUBERNETES_* environment variables
    • etcd: TLS mounts and PATRONI_ETCD3_* configuration
  • RBAC requirements

    • Kubernetes: Endpoint-related permissions
    • etcd: no Kubernetes DCS permissions
  • Service behavior

    • Kubernetes: Patroni-managed leader/config Services
    • etcd: no DCS Services
  • Runtime observations

    • Kubernetes: pod annotations and Endpoints
    • etcd: Patroni REST API and etcd-backed state

Design rules

  1. No DCS branching outside internal/patroni/dcs.

    Callers should request capabilities and handle results generically.

  2. UsesExternalDCS() should be removed after migration.

    DCSType() remains only where backend selection or CRD validation requires it.

  3. Backend implementations are stateless.

    Dependencies such as client/executor are passed as parameters.

  4. The interface can evolve during implementation.

    The goal is to define the ownership boundary: DCS-specific behavior belongs in the backend, while generic Patroni logic stays outside.

Import direction

The dependency direction remains:

controller → patroni
controller → dcs → patroni

patroni does not import dcs.

The controller wires the two together by passing backend-provided data into existing Patroni helpers.

The rule of thumb:

  • Logic that exists because of a specific DCS belongs in dcs.
  • Generic Patroni functionality remains in patroni.

@mayankshah1607

mayankshah1607 commented Aug 4, 2026

Copy link
Copy Markdown
Member

@yoav-katz The design looks good to me, pretty much also what I had in mind. Lets go ahead and create a separate draft PR. Since these changes will be huge, I propose we do it in 2 stages:

  1. Refactor the existing code and kubernetes DCS to this design. Make sure we migrate all the unit tests, and do not break any existing features
  2. Add the etcd implementation

@yoav-katz

Copy link
Copy Markdown
Contributor Author

Just double checking I understood correctly - you want me to draft this PR and create two other PR stacked on each other
The first for adding the dcs interface and the other is adding etcd as a different dcs

@mayankshah1607

Copy link
Copy Markdown
Member

Yes correct, in the first PR lets add the interface and refactor the existing code behind a Kubernetes DCS implementation, then another PR for an Etcd implementation

@yoav-katz

yoav-katz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@yoav-katz yoav-katz closed this Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants