Skip to content

feat(tls): support handing TLS over from BYO Secrets to operator-managed certs - #349

Draft
Timofei Larkin (lllamnyp) wants to merge 4 commits into
mainfrom
feat/tls-byo-to-certmanager
Draft

feat(tls): support handing TLS over from BYO Secrets to operator-managed certs#349
Timofei Larkin (lllamnyp) wants to merge 4 commits into
mainfrom
feat/tls-byo-to-certmanager

Conversation

@lllamnyp

@lllamnyp Timofei Larkin (lllamnyp) commented Jul 30, 2026

Copy link
Copy Markdown
Member

Problem

A cluster installed with user-provided TLS Secrets has no route onto operator-managed cert-manager issuance. spec.tls is CEL-immutable post-create, so the only way to change where the material comes from is delete-and-recreate — which for etcd means destroying the data.

That is a real dead end, not a theoretical one. Charts that predate the v1alpha2 operator issue their own Certificates and hand the Secrets to the cluster via serverSecretRef / secretRef. Those charts also tend to issue SANs that the operator's GenerateNamed member names do not match, and there is currently no supported way to move such a cluster onto material the operator generates correctly.

What this adds

Exactly one permitted post-create change to spec.tls:

  • client.serverSecretRefclient.certManager
  • peer.secretRefpeer.certManager

Everything else about the subtree stays frozen. The handover is one-way, it may not flip whether client mTLS is in effect, and it cannot turn a plane on or off. Adding or removing tls, tls.client or tls.peer is still rejected, as is certManagersecretRef.

The operator drives the move end to end:

  1. Emit the new Certificates, refusing to adopt any that another controller already owns.
  2. Wait until every Secret the new spec names actually carries the keys etcd will be started against.
  3. Repoint every member in one pass; the member controller rebuilds their Pods.

Progress is reported on a new TLSHandover condition: AwaitingMaterialRollingMembersComplete, or Blocked.

Three decisions worth reviewing

The roll is simultaneous, not rolling. The new material is signed by a different CA, so an old member and a new member cannot authenticate to each other at all — every intermediate state of a staggered roll is a split, quorum-less cluster. A one-at-a-time roll would not avoid the outage, it would stretch it across N pod startups instead of one. So all members are repointed together and come back on consistent material. A brief outage is the accepted cost of the operation.

Name conflicts fail closed. The operator's targets are <cluster>-server / <cluster>-peer. For a cluster named etcd — the common case — those are byte-identical to what a chart already emits, and Helm/Flux do not set ownerReferences on chart resources, so ownership is a clean discriminator. Adopting the object would mean this operator and the chart's controller reconciling one object forever, and would silently repoint a live cluster at TLS material the operator never issued. Instead it reports:

TLSHandover=False  reason=Blocked
  cannot take over Certificate "etcd-peer": it exists but another controller owns it …

The unblock is to stop the chart emitting it. cert-manager leaves the Secret behind when its Certificate is deleted, so the operator's replacement Certificate reissues into the same Secret with no gap.

A conflict does not abort the reconcile. The cluster is still serving on the material it holds, so the loop continues to updateStatus and its health reporting stays live. Bailing out early would freeze readyMembers and the health conditions at whatever they were — a cluster that silently stops reporting is worse than one that reports it cannot converge.

Notable behaviour change

TLS becomes the one mirrored member field that is re-templated after creation. Storage, Resources and Version are deliberately frozen per member — existing members are not re-templated when the cluster spec moves. TLS is different in kind: holding the wrong material does not degrade a member, it isolates it completely. Correspondingly, ensurePod now tears down a Pod whose mounted TLS Secret names no longer match its member spec (Pod volumes are immutable, so a rebuild is the only route). Content changes — a cert-manager renewal writing a fresh leaf into the same Secret — deliberately do not trigger a rebuild.

Testing

make manifests generate fmt vet clean; go test ./api/... ./controllers/... green.

New coverage:

  • CEL, via envtest against a real apiserver (api/v1alpha2/cel_validation_test.go): handover accepted on both planes; reverse rejected; a handover that silently drops client mTLS rejected; the mTLS-preserving form accepted; adding a tls.peer subtree post-create still rejected.
  • Handover driver (controllers/tls_handover_test.go): no member is touched while material is missing; a Secret that exists but is missing tls.key counts as not ready; all members repointed in one pass; each member gets its own copy of the derived TLS view; a conflict is reported without touching members and without taking over the reconcile; no condition appears on a cluster that never drifted; the in-flight condition settles to Complete.
  • Conflict guard (ensureCertificate): refuses a foreign-owned Certificate; the pre-existing create-once test was updated to seed an operator-owned Certificate, which is what makes it the "ours, leave it alone" case rather than the conflict case.
  • Pod roll: a Pod mounting superseded Secrets is deleted; one on current material is left alone (otherwise the member spins in a delete loop).

Docs

Runbook at docs/operations.md#handing-tls-over-to-the-operator, including the condition table and what to do when it stalls. README.md, docs/concepts.md (validation table and TLS section) and docs/installation.md all claimed the subtree was wholly immutable; corrected.

Summary by CodeRabbit

  • New Features

    • Added in-place migration from user-provided TLS Secrets to operator-managed cert-manager certificates.
    • Added TLS handover status reporting, including waiting, rolling, blocked, and complete states.
    • Automatically refreshes cluster member Pods when mounted TLS material changes.
  • Bug Fixes

    • Prevents adoption of certificates managed by another controller.
    • Blocks unsafe or unsupported TLS transitions, including reverse migration.
  • Documentation

    • Documented TLS handover requirements, limitations, monitoring, and expected brief outage.

…ged certs

A cluster installed with user-provided TLS Secrets had no route onto
operator-managed cert-manager issuance: spec.tls was CEL-immutable
post-create, so the only way to change the source of the material was to
delete and recreate the cluster — i.e. to destroy its data.

Permit exactly one post-create change to spec.tls: client.serverSecretRef →
client.certManager, and peer.secretRef → peer.certManager. Everything else
about the subtree stays frozen. The handover is one-way, may not flip whether
client mTLS is in effect, and cannot turn a plane on or off.

The operator drives the move itself:

  1. It emits the new Certificates, refusing to adopt any that another
     controller already owns — the realistic case, since a chart installing a
     cluster named `etcd` emits `etcd-peer` and `etcd-server` under exactly
     the names the operator wants. Adopting would mean two controllers
     reconciling one object forever, and would repoint a live cluster at
     material the operator never issued. It reports TLSHandover=False/Blocked
     and names the object instead.
  2. It waits until every Secret the new spec names carries the keys etcd
     will be started against. Repointing before that strands the cluster on a
     Secret that does not exist, with the old material already gone from its
     spec.
  3. It repoints every member in one pass and lets the member controller
     rebuild their Pods together.

Step 3 is a simultaneous roll, not a rolling restart, and that is the whole
design. The new material is signed by a different CA, so an old member and a
new one cannot authenticate to each other at all: every intermediate state of
a staggered roll is a split cluster. Rolling one at a time would stretch the
outage across N pod startups instead of one. Brief downtime is the accepted
cost.

Progress is reported on a new TLSHandover condition rather than as a reason on
Available/Degraded/Progressing, which updateStatus rewrites from quorum health
on every pass. A conflict is deliberately not fatal to the reconcile either:
the cluster keeps serving on the material it already holds, and freezing the
loop would leave its readyMembers and health conditions stale — a cluster that
silently stops reporting is worse than one that reports it cannot converge.

TLS is also now the one mirrored member field that is re-templated after
creation. Storage, Resources and Version are frozen per member on purpose;
holding the wrong TLS material does not degrade a member, it isolates it.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added api-change controllers documentation Improvements or additions to documentation feature New feature or request labels Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@lllamnyp, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cc1411d2-8b8e-411c-b45b-40b08cba67d3

📥 Commits

Reviewing files that changed from the base of the PR and between a251e5b and 0e65c23.

📒 Files selected for processing (4)
  • controllers/tls_handover.go
  • controllers/tls_handover_test.go
  • docs/concepts.md
  • docs/operations.md
📝 Walkthrough

Walkthrough

The PR adds a one-way BYO Secret to cert-manager TLS handover, granular CEL validation, ownership-conflict handling, handover status conditions, member and Pod reconciliation, tests, and operational documentation.

Changes

TLS handover contract and admission validation

Layer / File(s) Summary
TLS handover contract and admission validation
api/v1alpha2/etcdcluster_types.go, api/v1alpha2/cel_validation_test.go, charts/etcd-operator/crd-bases/..., README.md, docs/*
TLS client and peer configuration now permits only the specified one-way SecretRef-to-cert-manager transitions, preserves client mTLS posture, and reports handover lifecycle conditions. Admission tests cover accepted, rejected, and subtree-addition updates. Documentation describes the constraints and runbook.

TLS material ownership and reconcile wiring

Layer / File(s) Summary
TLS material ownership and reconcile wiring
controllers/etcdcluster_controller.go, controllers/tls_handover.go, controllers/etcdcluster_controller_test.go, controllers/tls_handover_test.go
Certificate conflicts from foreign-owned resources are classified and passed to the handover phase, while cluster-owned certificates remain unchanged.

TLS handover readiness and member repointing

Layer / File(s) Summary
TLS handover readiness and member repointing
controllers/tls_handover.go, controllers/tls_handover_test.go
The controller waits for required Secret keys, updates stale members together, records AwaitingMaterial, RollingMembers, Blocked, and Complete states, and requeues during the transition.

Member Pod TLS refresh

Layer / File(s) Summary
Member Pod TLS refresh
controllers/etcdmember_controller.go, controllers/tls_handover.go, controllers/tls_handover_test.go
Existing Pods are deleted when mounted TLS Secret names differ from member references and are retained when they match.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EtcdCluster
  participant EtcdClusterReconciler
  participant CertManagerSecrets
  participant EtcdMembers
  participant MemberPods
  EtcdCluster->>EtcdClusterReconciler: update TLS references
  EtcdClusterReconciler->>CertManagerSecrets: check certificate material
  CertManagerSecrets-->>EtcdClusterReconciler: return readiness or missing keys
  EtcdClusterReconciler->>EtcdMembers: repoint stale TLS references
  EtcdMembers->>MemberPods: expose updated Secret references
  EtcdClusterReconciler->>EtcdCluster: update TLSHandover condition
Loading

Suggested reviewers: androndo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: a one-way TLS handover from BYO Secrets to operator-managed certs.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tls-byo-to-certmanager

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

A conflict short-circuited on the stale-member check, so a cluster whose
members were already aligned reported TLSHandover=Complete while the
operator still could not own its own Certificates.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (3)
docs/concepts.md (1)

180-180: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale "Four CEL rules" count.

The table this sentence introduces already lists well over a dozen rules, and this PR adds two more (tls.client/tls.peer add/remove and the handover-exception rules) to the same table, widening the drift further. Since the diff already touches this section, worth dropping the specific count or updating it.

✏️ Suggested fix
-Four CEL `x-kubernetes-validations` rules on `EtcdClusterSpec` are evaluated at admission time.
+Several CEL `x-kubernetes-validations` rules on `EtcdClusterSpec` are evaluated at admission time.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/concepts.md` at line 180, Update the CEL validation description in the
EtcdClusterSpec documentation to remove the stale “Four” count, while preserving
the existing Kubernetes version guidance and references to quantity(). Use
wording that accurately applies to all x-kubernetes-validations rules listed in
the table.
docs/operations.md (2)

218-221: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a language hint to the fenced example block.

Flagged by markdownlint (MD040) — the TLSHandover=False ... example fence has no language specified.

✏️ Suggested fix
-```
+```text
 TLSHandover=False  reason=Blocked
   cannot take over Certificate "etcd-peer": it exists but another controller owns it …
</details>

As per static analysis hints (markdownlint-cli2, MD040 fenced-code-language).

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/operations.md around lines 218 - 221, Update the fenced example block
containing the TLSHandover output to specify the text language, using the
existing content unchanged.


</details>

<!-- cr-comment:v1:b4bb2e609ffeb826173948e6 -->

_Source: Linters/SAST tools_

---

`214-223`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_

**"Free up the names" omits the operator-client Certificate.**

Per [Census of certs](concepts.md#census-of-certs) the operator can emit up to three Certificates (server, operator-client, peer). This runbook step only names `<cluster>-server` and `<cluster>-peer` — a chart-collision on `<cluster>-operator-client` (relevant whenever the handover target has mTLS on) would hit the same `Blocked` condition but isn't mentioned here, which could leave a reader stuck if that's the one that collides.

As per coding guidelines the docs should stay consistent with the operator's actual behavior described in concepts.md.

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/operations.md around lines 214 - 223, The “Free up the names” runbook
step omits the operator-client Certificate. Update the Certificate list and
collision example in this section to include -operator-client alongside
-server and -peer, matching the operator behavior documented
in the Census of certs section; preserve the existing Secret retention and
reissue guidance.


</details>

<!-- cr-comment:v1:e9bc5fbad5186870f086dc94 -->

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @docs/concepts.md:

  • Line 180: Update the CEL validation description in the EtcdClusterSpec
    documentation to remove the stale “Four” count, while preserving the existing
    Kubernetes version guidance and references to quantity(). Use wording that
    accurately applies to all x-kubernetes-validations rules listed in the table.

In @docs/operations.md:

  • Around line 218-221: Update the fenced example block containing the
    TLSHandover output to specify the text language, using the existing content
    unchanged.
  • Around line 214-223: The “Free up the names” runbook step omits the
    operator-client Certificate. Update the Certificate list and collision example
    in this section to include -operator-client alongside -server
    and -peer, matching the operator behavior documented in the Census of
    certs section; preserve the existing Secret retention and reissue guidance.

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: defaults

**Review profile**: CHILL

**Plan**: Pro Plus

**Run ID**: `25d7e38d-d503-449b-b204-e78fca3ff942`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 9c5d8961fb8dc8c62be4136c50fd6679490d1a82 and a251e5bd44f1763e09bd81ac7f1b071c36bfdfc7.

</details>

<details>
<summary>📒 Files selected for processing (12)</summary>

* `README.md`
* `api/v1alpha2/cel_validation_test.go`
* `api/v1alpha2/etcdcluster_types.go`
* `charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml`
* `controllers/etcdcluster_controller.go`
* `controllers/etcdcluster_controller_test.go`
* `controllers/etcdmember_controller.go`
* `controllers/tls_handover.go`
* `controllers/tls_handover_test.go`
* `docs/concepts.md`
* `docs/installation.md`
* `docs/operations.md`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Name the operator-client Certificate in the collision step — with client
mTLS on the operator emits three Certificates, and a chart collision on
that one hits the same Blocked path but was not mentioned. Note that only
the first collision is reported per pass.

Also drop the stale "Four CEL rules" count, which this branch widens, and
label the example fence.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
tlsMountsOutOfDate keyed off the operator's own volume names (tls-client /
tls-peer). A member adopted in place by etcd-migrate keeps the legacy
StatefulSet's Pod — adoption rewrites labels and owner refs but never
restarts it — so its volumes carry different names while its member spec
does name TLS Secrets. The predicate read that as drift and ensurePod would
have deleted every adopted Pod on the first reconcile after a migration,
which is exactly the outcome in-place adoption exists to avoid.

Treat a Pod carrying none of the operator's TLS volumes as out of scope: it
is either plaintext or a shape the operator did not build. Adopted members
keep their material until they are rolled onto the operator's own Pod shape.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
@lllamnyp

Copy link
Copy Markdown
Member Author

On the kamaji-datastore e2e failure

The failing test is TestPVCMemberCrashLoopSelfHeal, not the Kamaji datastore test — that one passed, including the full member churn and teardown. The failure is pre-existing on main and unrelated to this branch:

member_selfheal_test.go:93: timed out waiting for stuck member "etcd-8pffl" deleted for
  replacement: victim "etcd-8pffl" still present (crash-loop not yet past threshold)

Self-heal is gated on !member.Spec.Bootstrap, so when the test happens to pick the bootstrap seed as its victim, replacement can never fire and the wait times out. That is exactly what #345 ("test(e2e): pick a non-seed victim in the self-heal test") fixes; it is still open, and this branch is cut from the current main tip, so it inherits the flake.

This change cannot be implicated: the self-heal test builds a deliberately plaintext cluster (member_selfheal_test.go:54), so member.Spec.TLS is nil and the new Pod-rebuild predicate returns false unconditionally for every member in it.

Happy to rebase once #345 lands if you'd rather see a green run before merging.

One fix that came out of investigating it

Chasing the above surfaced a real bug in this branch, now fixed in 0e65c23.

tlsMountsOutOfDate keyed off the operator's own volume names (tls-client / tls-peer). A member adopted in place by etcd-migrate keeps the Pod the legacy StatefulSet created — adoptPod rewrites labels and owner refs and explicitly does not restart it — so its volumes carry different names while its member spec does name TLS Secrets. The predicate read that as drift, and ensurePod would have deleted every adopted Pod on the first reconcile after a migration, with no handover requested at all. That is precisely the outcome in-place adoption exists to avoid.

A Pod carrying none of the operator's TLS volumes is now treated as out of scope — it is either plaintext or a shape the operator did not author. Adopted members keep their material until they are rolled onto the operator's own Pod shape, which the runbook now states. Covered by a regression case in TestTLSMountsOutOfDate (verified to fail without the guard).

@lllamnyp
Timofei Larkin (lllamnyp) marked this pull request as draft July 30, 2026 12:56
@lllamnyp

Copy link
Copy Markdown
Member Author

Parking this as a draft.

The TLS-reload research (what etcd can and cannot hot-reload) shows the simultaneous roll this PR is built around is both unnecessary and, on memory-backed clusters, unrecoverable:

  • etcd re-reads leaf cert/key on every handshake, but CA trust pools are built once at process start and frozen for the process lifetime. A CA change needs a restart; rewriting the file is silently a no-op.
  • etcd trusts multiple CAs concatenated into the single file --trusted-ca-file names. So a CA handover decomposes into widen trust (one sequential restart per member, no split at any step because every member still presents an old-CA leaf and every member trusts the old CA) and swap leaves. There is no intermediate state where a pair of members cannot authenticate, so the roll does not need to be simultaneous.
  • reconcileTLSHandover has no storage-medium check, and the Pod teardown is a direct delete rather than an eviction, so the emitted PodDisruptionBudget does not gate it. On a memory-backed cluster every member's tmpfs data dir is destroyed in the same instant and no survivor holds the state.

Two further issues in the same area: pointing --trusted-ca-file at cert-manager's ca.crt narrows the trust set to new-CA-only on reissue — harmless at the time because the pool is frozen, and therefore latent until an unrelated restart isolates a member — and buildOperatorTLSConfig takes ca.crt from the server Secret, so the operator loses the ability to verify the cluster it is mid-way through converting.

Rather than patch this incrementally, the rollout mechanism is being designed generally: spec.tls is only one of several immutable-or-not-honoured settings (storage medium and class, auth, resources, version), and they all want the same thing — a safe, sequenced member roll with a real health gate between steps. This PR will be revisited once that design lands.

The parts of it that stand on their own regardless — the CEL relaxation, the fail-closed conflict guard on foreign-owned Certificates, gating the repoint on material actually being populated, and not rebuilding Pods the operator did not author — will carry over.

Note also that the red kamaji-datastore check is unrelated: TestKamajiDataStore passes, and the failure is TestPVCMemberCrashLoopSelfHeal hitting the seed-victim bug that #345 fixes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-change controllers documentation Improvements or additions to documentation feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant