Skip to content

Commit 4dab563

Browse files
androndoclaude
andcommitted
fix(main): make peer-auto-tls a reserved annotation, not typed spec
Address review feedback (PR #330): an unauthenticated peer plane must not be a discoverable, CEL-blessed option for new clusters. - Drop spec.tls.peer.autoTLS (and the PeerAutoTLS type); revert the PeerTLS CEL rule to the two-way has(secretRef) != has(certManager) union. - Carry legacy --peer-auto-tls forward on a cluster-level reserved annotation etcd-operator.cozystack.io/peer-auto-tls instead. deriveMemberTLS reads it (superseded by an explicit peer secretRef/certManager) and propagates it to every member it builds; clusterPeerScheme treats it as https. - etcd-migrate stamps the annotation and raises it as a SecurityWarning, rendered with a loud marker in the plan AND re-surfaced in the post-apply summary so an unauthenticated peer plane can't be adopted unnoticed. - Document the mode, carry-forward and off-ramp in docs/migration.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 10b0bd1 commit 4dab563

15 files changed

Lines changed: 218 additions & 145 deletions

api/v1alpha2/cel_validation_test.go

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -482,36 +482,6 @@ func TestCEL_TLSPeerCertManagerAndSecretRefMutuallyExclusive(t *testing.T) {
482482
}
483483
}
484484

485-
// TestCEL_TLSPeerAutoTLS: autoTLS alone is accepted; autoTLS combined with
486-
// another peer source is rejected by the exactly-one-of rule.
487-
func TestCEL_TLSPeerAutoTLS(t *testing.T) {
488-
skipIfNoEnvtest(t)
489-
ctx := context.Background()
490-
491-
// autoTLS alone — accepted.
492-
ok := validCluster("tls-peer-autotls")
493-
ok.Spec.TLS = &lll.EtcdClusterTLS{Peer: &lll.PeerTLS{AutoTLS: &lll.PeerAutoTLS{}}}
494-
if err := k8s.Create(ctx, ok); err != nil {
495-
t.Fatalf("apiserver rejected peer.autoTLS alone: %v", err)
496-
}
497-
_ = k8s.Delete(ctx, ok)
498-
499-
// autoTLS + secretRef — rejected.
500-
bad := validCluster("tls-peer-autotls-both")
501-
bad.Spec.TLS = &lll.EtcdClusterTLS{Peer: &lll.PeerTLS{
502-
AutoTLS: &lll.PeerAutoTLS{},
503-
SecretRef: &corev1.LocalObjectReference{Name: "fake-peer-tls"},
504-
}}
505-
err := k8s.Create(ctx, bad)
506-
if err == nil {
507-
_ = k8s.Delete(ctx, bad)
508-
t.Fatalf("apiserver accepted peer.autoTLS + peer.secretRef; expected rejection")
509-
}
510-
if !strings.Contains(err.Error(), "exactly one of spec.tls.peer.secretRef") {
511-
t.Fatalf("error did not mention peer mutual exclusion: %v", err)
512-
}
513-
}
514-
515485
// TestCEL_HappyPathAccepts is a negative-side guard: a fully valid
516486
// cluster spec must pass the apiserver. Catches accidental rule
517487
// inversions and over-broad CEL expressions.

api/v1alpha2/etcdcluster_types.go

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ type ClientCertManagerTLS struct {
194194
// PeerTLS configures TLS for the etcd peer API. When PeerTLS is set, peer
195195
// is always mTLS.
196196
//
197-
// +kubebuilder:validation:XValidation:rule="[has(self.secretRef), has(self.certManager), has(self.autoTLS)].filter(x, x).size() == 1",message="exactly one of spec.tls.peer.secretRef, spec.tls.peer.certManager or spec.tls.peer.autoTLS must be set"
197+
// +kubebuilder:validation:XValidation:rule="has(self.secretRef) != has(self.certManager)",message="exactly one of spec.tls.peer.secretRef or spec.tls.peer.certManager must be set"
198198
type PeerTLS struct {
199199
// SecretRef points at a Secret in the cluster's namespace holding
200200
// the peer cert+key in the standard kubernetes.io/tls shape:
@@ -203,35 +203,17 @@ type PeerTLS struct {
203203
// connections — and --peer-trusted-ca-file is always populated).
204204
// The peer cert MUST carry both serverAuth and clientAuth in EKU.
205205
//
206-
// Mutually exclusive with CertManager and AutoTLS.
206+
// Mutually exclusive with CertManager.
207207
// +optional
208208
SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"`
209209

210210
// CertManager configures operator-driven TLS material provisioning
211211
// for the peer plane via cert-manager.io/v1 Certificate resources.
212-
// Mutually exclusive with SecretRef and AutoTLS.
212+
// Mutually exclusive with SecretRef.
213213
// +optional
214214
CertManager *PeerCertManagerTLS `json:"certManager,omitempty"`
215-
216-
// AutoTLS runs the peer API with etcd's --peer-auto-tls: each member
217-
// generates its own self-signed peer certificate and there is NO shared
218-
// CA, so peer traffic is encrypted but NOT authenticated — any workload
219-
// that can reach :2380 and speak TLS can join or impersonate a peer.
220-
// This is INSECURE and exists only to adopt legacy clusters that ran the
221-
// previous operator's unconditional --peer-auto-tls default in place:
222-
// etcd-migrate sets it so replacement/scaled members interoperate with
223-
// the still-auto-tls adopted members without a CA rotation. Move to
224-
// SecretRef or CertManager (real mTLS) when you can — that is a
225-
// delete-and-recreate since spec.tls is immutable. Mutually exclusive
226-
// with SecretRef and CertManager.
227-
// +optional
228-
AutoTLS *PeerAutoTLS `json:"autoTLS,omitempty"`
229215
}
230216

231-
// PeerAutoTLS enables etcd's --peer-auto-tls for the peer plane. Empty struct:
232-
// its presence is the signal. INSECURE — see PeerTLS.AutoTLS.
233-
type PeerAutoTLS struct{}
234-
235217
// PeerCertManagerTLS configures operator-driven TLS for the peer API.
236218
type PeerCertManagerTLS struct {
237219
// IssuerRef is the Issuer or ClusterIssuer that signs the peer cert.

api/v1alpha2/etcdmember_types.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,14 @@ type EtcdMemberTLS struct {
4545
// +optional
4646
PeerSecretRef *corev1.LocalObjectReference `json:"peerSecretRef,omitempty"`
4747

48-
// PeerAutoTLS mirrors "EtcdClusterTLS.Peer.AutoTLS is set": run the peer
49-
// API with etcd's --peer-auto-tls (self-signed, no shared CA) instead of
50-
// mounting a peer secret. INSECURE — peer is encrypted but NOT
51-
// authenticated. Mutually exclusive with PeerSecretRef.
48+
// PeerAutoTLS is operator-managed plumbing: it carries the cluster's
49+
// reserved "etcd-operator.cozystack.io/peer-auto-tls" annotation down to
50+
// the member so buildPod renders etcd's --peer-auto-tls (self-signed, no
51+
// shared CA) instead of mounting a peer secret. INSECURE — peer is
52+
// encrypted but NOT authenticated. Set only on clusters adopted from a
53+
// legacy --peer-auto-tls cluster, and never together with PeerSecretRef
54+
// (an explicit peer secret supersedes the annotation). Users do not set
55+
// this directly; the cluster controller derives it.
5256
// +optional
5357
PeerAutoTLS bool `json:"peerAutoTLS,omitempty"`
5458
}

api/v1alpha2/zz_generated.deepcopy.go

Lines changed: 0 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,25 +1515,11 @@ spec:
15151515
is symmetric and there is no useful encrypt-only-no-identity mode
15161516
for it.
15171517
properties:
1518-
autoTLS:
1519-
description: |-
1520-
AutoTLS runs the peer API with etcd's --peer-auto-tls: each member
1521-
generates its own self-signed peer certificate and there is NO shared
1522-
CA, so peer traffic is encrypted but NOT authenticated — any workload
1523-
that can reach :2380 and speak TLS can join or impersonate a peer.
1524-
This is INSECURE and exists only to adopt legacy clusters that ran the
1525-
previous operator's unconditional --peer-auto-tls default in place:
1526-
etcd-migrate sets it so replacement/scaled members interoperate with
1527-
the still-auto-tls adopted members without a CA rotation. Move to
1528-
SecretRef or CertManager (real mTLS) when you can — that is a
1529-
delete-and-recreate since spec.tls is immutable. Mutually exclusive
1530-
with SecretRef and CertManager.
1531-
type: object
15321518
certManager:
15331519
description: |-
15341520
CertManager configures operator-driven TLS material provisioning
15351521
for the peer plane via cert-manager.io/v1 Certificate resources.
1536-
Mutually exclusive with SecretRef and AutoTLS.
1522+
Mutually exclusive with SecretRef.
15371523
properties:
15381524
issuerRef:
15391525
description: |-
@@ -1570,7 +1556,7 @@ spec:
15701556
connections — and --peer-trusted-ca-file is always populated).
15711557
The peer cert MUST carry both serverAuth and clientAuth in EKU.
15721558
1573-
Mutually exclusive with CertManager and AutoTLS.
1559+
Mutually exclusive with CertManager.
15741560
properties:
15751561
name:
15761562
default: ""
@@ -1585,10 +1571,9 @@ spec:
15851571
x-kubernetes-map-type: atomic
15861572
type: object
15871573
x-kubernetes-validations:
1588-
- message: exactly one of spec.tls.peer.secretRef, spec.tls.peer.certManager
1589-
or spec.tls.peer.autoTLS must be set
1590-
rule: '[has(self.secretRef), has(self.certManager), has(self.autoTLS)].filter(x,
1591-
x).size() == 1'
1574+
- message: exactly one of spec.tls.peer.secretRef or spec.tls.peer.certManager
1575+
must be set
1576+
rule: has(self.secretRef) != has(self.certManager)
15921577
type: object
15931578
topologySpreadConstraints:
15941579
description: |-

charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdmembers.yaml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,10 +1338,14 @@ spec:
13381338
x-kubernetes-map-type: atomic
13391339
peerAutoTLS:
13401340
description: |-
1341-
PeerAutoTLS mirrors "EtcdClusterTLS.Peer.AutoTLS is set": run the peer
1342-
API with etcd's --peer-auto-tls (self-signed, no shared CA) instead of
1343-
mounting a peer secret. INSECURE — peer is encrypted but NOT
1344-
authenticated. Mutually exclusive with PeerSecretRef.
1341+
PeerAutoTLS is operator-managed plumbing: it carries the cluster's
1342+
reserved "etcd-operator.cozystack.io/peer-auto-tls" annotation down to
1343+
the member so buildPod renders etcd's --peer-auto-tls (self-signed, no
1344+
shared CA) instead of mounting a peer secret. INSECURE — peer is
1345+
encrypted but NOT authenticated. Set only on clusters adopted from a
1346+
legacy --peer-auto-tls cluster, and never together with PeerSecretRef
1347+
(an explicit peer secret supersedes the annotation). Users do not set
1348+
this directly; the cluster controller derives it.
13451349
type: boolean
13461350
peerSecretRef:
13471351
description: |-

cmd/etcd-migrate/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ func runMigration(ctx context.Context, cfg *Config, stdin io.Reader, stdout io.W
221221
fmt.Fprintln(stdout, "\nNEXT: scale the new operator up — it will take over the adopted clusters without touching the pods:\n kubectl -n "+
222222
mustNamespace(cfg.NewController)+" scale deploy "+mustName(cfg.NewController)+" --replicas=1")
223223
}
224+
renderSecuritySummary(stdout, plans)
224225
printCRDNotice(stdout)
225226
return errorIfPlanFailed(plans)
226227
}

cmd/etcd-migrate/output.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ func render(w io.Writer, plans []migrate.ResourcePlan) {
3030
for _, e := range p.Errors {
3131
fmt.Fprintf(w, " ERROR: %s\n", e)
3232
}
33+
for _, sw := range p.SecurityWarnings {
34+
fmt.Fprintf(w, " ⚠️ SECURITY: %s\n", sw)
35+
}
3336
for _, warn := range p.Warnings {
3437
fmt.Fprintf(w, " warning: %s\n", warn)
3538
}
@@ -85,6 +88,29 @@ func renderManifest(w io.Writer, obj client.Object) {
8588
_, _ = w.Write(data)
8689
}
8790

91+
// renderSecuritySummary re-surfaces every SecurityWarning from the plans that
92+
// were actually adopted, AFTER --apply has run. The pre-apply plan already
93+
// shows them, but for a security-posture downgrade (e.g. an unauthenticated
94+
// --peer-auto-tls peer plane) that is not enough: the plan scrolls past, so the
95+
// operator must see the downgrade again in the closing summary, once it is a
96+
// fait accompli. No-op when nothing was downgraded.
97+
func renderSecuritySummary(w io.Writer, plans []migrate.ResourcePlan) {
98+
var any bool
99+
for i := range plans {
100+
p := &plans[i]
101+
if p.Action != migrate.ActionAdopt || len(p.SecurityWarnings) == 0 {
102+
continue
103+
}
104+
if !any {
105+
fmt.Fprintln(w, "\n⚠️ SECURITY — review before relying on the adopted clusters:")
106+
any = true
107+
}
108+
for _, sw := range p.SecurityWarnings {
109+
fmt.Fprintf(w, " • %s/%s: %s\n", p.Namespace, p.SourceName, sw)
110+
}
111+
}
112+
}
113+
88114
// printCRDNotice reminds about the one cleanup step the tool never performs.
89115
func printCRDNotice(w io.Writer) {
90116
fmt.Fprintln(w, `

controllers/etcdmember_controller.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -717,8 +717,9 @@ func (r *EtcdMemberReconciler) buildPod(member *lll.EtcdMember) *corev1.Pod {
717717
// INSECURE legacy-compat peer mode: etcd generates a self-signed peer
718718
// cert per member with no shared CA, so peer is encrypted but NOT
719719
// authenticated and there is nothing to mount. Only reached for
720-
// clusters adopted from a --peer-auto-tls legacy cluster (see
721-
// EtcdClusterTLS.Peer.AutoTLS); etcd-migrate sets it.
720+
// clusters adopted from a --peer-auto-tls legacy cluster: the cluster
721+
// controller derives this from the reserved AnnPeerAutoTLS annotation
722+
// etcd-migrate stamps (see AnnPeerAutoTLS).
722723
cmd = append(cmd, "--peer-auto-tls")
723724
case peerTLS:
724725
cmd = append(cmd,

controllers/etcdmember_controller_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2493,6 +2493,7 @@ func TestDeriveMemberTLS(t *testing.T) {
24932493
hasClient bool
24942494
hasPeer bool
24952495
clientMTLS bool
2496+
peerAutoTLS bool
24962497
serverSecret string
24972498
opSecret string
24982499
peerSecret string
@@ -2569,6 +2570,29 @@ func TestDeriveMemberTLS(t *testing.T) {
25692570
}}}),
25702571
want: want{hasPeer: true, peerSecret: "etcd-peer-tls"},
25712572
},
2573+
{
2574+
// Legacy-compat --peer-auto-tls carried on the reserved cluster
2575+
// annotation (no typed spec.tls.peer) projects to PeerAutoTLS.
2576+
name: "peer-auto-tls annotation only",
2577+
in: func() *lll.EtcdCluster {
2578+
c := withName(&lll.EtcdCluster{})
2579+
c.Annotations = map[string]string{AnnPeerAutoTLS: "true"}
2580+
return c
2581+
}(),
2582+
want: want{peerAutoTLS: true},
2583+
},
2584+
{
2585+
// An explicit peer secretRef supersedes the annotation.
2586+
name: "peer secretRef beats peer-auto-tls annotation",
2587+
in: func() *lll.EtcdCluster {
2588+
c := withName(&lll.EtcdCluster{Spec: lll.EtcdClusterSpec{TLS: &lll.EtcdClusterTLS{
2589+
Peer: &lll.PeerTLS{SecretRef: &corev1.LocalObjectReference{Name: "p"}},
2590+
}}})
2591+
c.Annotations = map[string]string{AnnPeerAutoTLS: "true"}
2592+
return c
2593+
}(),
2594+
want: want{hasPeer: true, peerSecret: "p"},
2595+
},
25722596
}
25732597
for _, tc := range cases {
25742598
t.Run(tc.name, func(t *testing.T) {
@@ -2588,6 +2612,9 @@ func TestDeriveMemberTLS(t *testing.T) {
25882612
if (got.PeerSecretRef != nil) != tc.want.hasPeer {
25892613
t.Fatalf("hasPeer = %v; want %v", got.PeerSecretRef != nil, tc.want.hasPeer)
25902614
}
2615+
if got.PeerAutoTLS != tc.want.peerAutoTLS {
2616+
t.Fatalf("PeerAutoTLS = %v; want %v", got.PeerAutoTLS, tc.want.peerAutoTLS)
2617+
}
25912618
if got.ClientMTLS != tc.want.clientMTLS {
25922619
t.Fatalf("ClientMTLS = %v; want %v", got.ClientMTLS, tc.want.clientMTLS)
25932620
}

0 commit comments

Comments
 (0)