From 109bfcce92e7e1b960c2c2114665cbf87ed4b3b3 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Fri, 12 Jun 2026 11:32:41 +0400 Subject: [PATCH] fix(main): add support peer-auto-tls Signed-off-by: Andrey Kolkov --- api/v1alpha2/cel_validation_test.go | 2 +- api/v1alpha2/etcdmember_types.go | 11 ++++ ...tcd-operator.cozystack.io_etcdmembers.yaml | 11 ++++ cmd/etcd-migrate/main.go | 1 + cmd/etcd-migrate/output.go | 26 ++++++++ controllers/etcdmember_controller.go | 11 +++- controllers/etcdmember_controller_test.go | 60 +++++++++++++++++ controllers/helpers.go | 66 +++++++++++++++---- docs/migration.md | 41 ++++++++++++ internal/migrate/adopt.go | 45 +++++++++++-- internal/migrate/adopt_test.go | 58 ++++++++++++++++ internal/migrate/plan.go | 6 ++ 12 files changed, 319 insertions(+), 19 deletions(-) diff --git a/api/v1alpha2/cel_validation_test.go b/api/v1alpha2/cel_validation_test.go index cdee77d0..0ed601c8 100644 --- a/api/v1alpha2/cel_validation_test.go +++ b/api/v1alpha2/cel_validation_test.go @@ -477,7 +477,7 @@ func TestCEL_TLSPeerCertManagerAndSecretRefMutuallyExclusive(t *testing.T) { _ = k8s.Delete(ctx, c) t.Fatalf("apiserver accepted both peer.secretRef and peer.certManager; expected rejection") } - if !strings.Contains(err.Error(), "exactly one of spec.tls.peer.secretRef or spec.tls.peer.certManager") { + if !strings.Contains(err.Error(), "exactly one of spec.tls.peer.secretRef") { t.Fatalf("error did not mention peer mutual exclusion: %v", err) } } diff --git a/api/v1alpha2/etcdmember_types.go b/api/v1alpha2/etcdmember_types.go index 77072060..b6c737a7 100644 --- a/api/v1alpha2/etcdmember_types.go +++ b/api/v1alpha2/etcdmember_types.go @@ -44,6 +44,17 @@ type EtcdMemberTLS struct { // mTLS (--peer-client-cert-auth=true). // +optional PeerSecretRef *corev1.LocalObjectReference `json:"peerSecretRef,omitempty"` + + // PeerAutoTLS is operator-managed plumbing: it carries the cluster's + // reserved "etcd-operator.cozystack.io/peer-auto-tls" annotation down to + // the member so buildPod renders etcd's --peer-auto-tls (self-signed, no + // shared CA) instead of mounting a peer secret. INSECURE — peer is + // encrypted but NOT authenticated. Set only on clusters adopted from a + // legacy --peer-auto-tls cluster, and never together with PeerSecretRef + // (an explicit peer secret supersedes the annotation). Users do not set + // this directly; the cluster controller derives it. + // +optional + PeerAutoTLS bool `json:"peerAutoTLS,omitempty"` } // Condition types for EtcdMember. diff --git a/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdmembers.yaml b/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdmembers.yaml index b1e5222a..7d47d313 100644 --- a/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdmembers.yaml +++ b/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdmembers.yaml @@ -1336,6 +1336,17 @@ spec: type: string type: object x-kubernetes-map-type: atomic + peerAutoTLS: + description: |- + PeerAutoTLS is operator-managed plumbing: it carries the cluster's + reserved "etcd-operator.cozystack.io/peer-auto-tls" annotation down to + the member so buildPod renders etcd's --peer-auto-tls (self-signed, no + shared CA) instead of mounting a peer secret. INSECURE — peer is + encrypted but NOT authenticated. Set only on clusters adopted from a + legacy --peer-auto-tls cluster, and never together with PeerSecretRef + (an explicit peer secret supersedes the annotation). Users do not set + this directly; the cluster controller derives it. + type: boolean peerSecretRef: description: |- PeerSecretRef mirrors EtcdClusterTLS.Peer.SecretRef. When nil, the diff --git a/cmd/etcd-migrate/main.go b/cmd/etcd-migrate/main.go index 171793bb..d7f5995c 100644 --- a/cmd/etcd-migrate/main.go +++ b/cmd/etcd-migrate/main.go @@ -221,6 +221,7 @@ func runMigration(ctx context.Context, cfg *Config, stdin io.Reader, stdout io.W fmt.Fprintln(stdout, "\nNEXT: scale the new operator up — it will take over the adopted clusters without touching the pods:\n kubectl -n "+ mustNamespace(cfg.NewController)+" scale deploy "+mustName(cfg.NewController)+" --replicas=1") } + renderSecuritySummary(stdout, plans) printCRDNotice(stdout) return errorIfPlanFailed(plans) } diff --git a/cmd/etcd-migrate/output.go b/cmd/etcd-migrate/output.go index 89c9529c..ae7b6110 100644 --- a/cmd/etcd-migrate/output.go +++ b/cmd/etcd-migrate/output.go @@ -30,6 +30,9 @@ func render(w io.Writer, plans []migrate.ResourcePlan) { for _, e := range p.Errors { fmt.Fprintf(w, " ERROR: %s\n", e) } + for _, sw := range p.SecurityWarnings { + fmt.Fprintf(w, " ⚠️ SECURITY: %s\n", sw) + } for _, warn := range p.Warnings { fmt.Fprintf(w, " warning: %s\n", warn) } @@ -85,6 +88,29 @@ func renderManifest(w io.Writer, obj client.Object) { _, _ = w.Write(data) } +// renderSecuritySummary re-surfaces every SecurityWarning from the plans that +// were actually adopted, AFTER --apply has run. The pre-apply plan already +// shows them, but for a security-posture downgrade (e.g. an unauthenticated +// --peer-auto-tls peer plane) that is not enough: the plan scrolls past, so the +// operator must see the downgrade again in the closing summary, once it is a +// fait accompli. No-op when nothing was downgraded. +func renderSecuritySummary(w io.Writer, plans []migrate.ResourcePlan) { + var any bool + for i := range plans { + p := &plans[i] + if p.Action != migrate.ActionAdopt || len(p.SecurityWarnings) == 0 { + continue + } + if !any { + fmt.Fprintln(w, "\n⚠️ SECURITY — review before relying on the adopted clusters:") + any = true + } + for _, sw := range p.SecurityWarnings { + fmt.Fprintf(w, " • %s/%s: %s\n", p.Namespace, p.SourceName, sw) + } + } +} + // printCRDNotice reminds about the one cleanup step the tool never performs. func printCRDNotice(w io.Writer) { fmt.Fprintln(w, ` diff --git a/controllers/etcdmember_controller.go b/controllers/etcdmember_controller.go index 6b0a88b0..677fed0d 100644 --- a/controllers/etcdmember_controller.go +++ b/controllers/etcdmember_controller.go @@ -712,7 +712,16 @@ func (r *EtcdMemberReconciler) buildPod(member *lll.EtcdMember) *corev1.Pod { Name: "tls-client", MountPath: "/etc/etcd/tls/client", ReadOnly: true, }) } - if peerTLS { + switch { + case member.Spec.TLS != nil && member.Spec.TLS.PeerAutoTLS: + // INSECURE legacy-compat peer mode: etcd generates a self-signed peer + // cert per member with no shared CA, so peer is encrypted but NOT + // authenticated and there is nothing to mount. Only reached for + // clusters adopted from a --peer-auto-tls legacy cluster: the cluster + // controller derives this from the reserved AnnPeerAutoTLS annotation + // etcd-migrate stamps (see AnnPeerAutoTLS). + cmd = append(cmd, "--peer-auto-tls") + case peerTLS: cmd = append(cmd, "--peer-cert-file=/etc/etcd/tls/peer/tls.crt", "--peer-key-file=/etc/etcd/tls/peer/tls.key", diff --git a/controllers/etcdmember_controller_test.go b/controllers/etcdmember_controller_test.go index dcd24280..6a824f44 100644 --- a/controllers/etcdmember_controller_test.go +++ b/controllers/etcdmember_controller_test.go @@ -2150,6 +2150,39 @@ func TestBuildPod_PeerTLSAlwaysMTLS(t *testing.T) { } } +// TestBuildPod_PeerAutoTLS: the legacy-compat insecure peer mode emits +// --peer-auto-tls on an https peer listener and mounts NO peer secret (etcd +// self-signs; there is no shared CA and no client-cert-auth). +func TestBuildPod_PeerAutoTLS(t *testing.T) { + r := &EtcdMemberReconciler{} + pod := r.buildPod(&lll.EtcdMember{ + ObjectMeta: metav1.ObjectMeta{Name: "m", Namespace: "ns"}, + Spec: lll.EtcdMemberSpec{ + ClusterName: "test", Version: "3.5.17", Storage: lll.StorageSpec{Size: quickQty(t, "1Gi")}, + TLS: &lll.EtcdMemberTLS{PeerAutoTLS: true}, + }, + }) + cmd := pod.Spec.Containers[0].Command + if !cmdContains(cmd, "--listen-peer-urls=https://0.0.0.0:2380") { + t.Fatalf("peer listen URL not https: %v", cmd) + } + if !cmdContains(cmd, "--peer-auto-tls") { + t.Fatalf("expected --peer-auto-tls; got %v", cmd) + } + for _, unwanted := range []string{ + "--peer-cert-file=/etc/etcd/tls/peer/tls.crt", + "--peer-trusted-ca-file=/etc/etcd/tls/peer/ca.crt", + "--peer-client-cert-auth=true", + } { + if cmdContains(cmd, unwanted) { + t.Fatalf("auto-tls must not set BYO peer flag %q: %v", unwanted, cmd) + } + } + if v := volumeFor(pod, "tls-peer"); v != nil { + t.Fatalf("auto-tls must mount no peer secret; got volume %+v", v) + } +} + // TestBuildPod_AlwaysExposesMetricsPort guards the cozystack-shaped // monitoring contract: VMPodScrape (and equivalent Prometheus scrapers) // target the named "metrics" container port unconditionally, and the @@ -2460,6 +2493,7 @@ func TestDeriveMemberTLS(t *testing.T) { hasClient bool hasPeer bool clientMTLS bool + peerAutoTLS bool serverSecret string opSecret string peerSecret string @@ -2536,6 +2570,29 @@ func TestDeriveMemberTLS(t *testing.T) { }}}), want: want{hasPeer: true, peerSecret: "etcd-peer-tls"}, }, + { + // Legacy-compat --peer-auto-tls carried on the reserved cluster + // annotation (no typed spec.tls.peer) projects to PeerAutoTLS. + name: "peer-auto-tls annotation only", + in: func() *lll.EtcdCluster { + c := withName(&lll.EtcdCluster{}) + c.Annotations = map[string]string{AnnPeerAutoTLS: "true"} + return c + }(), + want: want{peerAutoTLS: true}, + }, + { + // An explicit peer secretRef supersedes the annotation. + name: "peer secretRef beats peer-auto-tls annotation", + in: func() *lll.EtcdCluster { + c := withName(&lll.EtcdCluster{Spec: lll.EtcdClusterSpec{TLS: &lll.EtcdClusterTLS{ + Peer: &lll.PeerTLS{SecretRef: &corev1.LocalObjectReference{Name: "p"}}, + }}}) + c.Annotations = map[string]string{AnnPeerAutoTLS: "true"} + return c + }(), + want: want{hasPeer: true, peerSecret: "p"}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -2555,6 +2612,9 @@ func TestDeriveMemberTLS(t *testing.T) { if (got.PeerSecretRef != nil) != tc.want.hasPeer { t.Fatalf("hasPeer = %v; want %v", got.PeerSecretRef != nil, tc.want.hasPeer) } + if got.PeerAutoTLS != tc.want.peerAutoTLS { + t.Fatalf("PeerAutoTLS = %v; want %v", got.PeerAutoTLS, tc.want.peerAutoTLS) + } if got.ClientMTLS != tc.want.clientMTLS { t.Fatalf("ClientMTLS = %v; want %v", got.ClientMTLS, tc.want.clientMTLS) } diff --git a/controllers/helpers.go b/controllers/helpers.go index eb6f7503..a67269ce 100644 --- a/controllers/helpers.go +++ b/controllers/helpers.go @@ -58,6 +58,22 @@ const ( // (validDataDirSubPath) — an annotation has no apiserver schema, so the // controller fails closed against a mount-escaping value. AnnDataDirSubPath = ReservedAnnotationPrefix + "data-dir-subpath" + + // AnnPeerAutoTLS, set to "true" on an EtcdCluster, runs the peer plane + // with etcd's --peer-auto-tls: per-member self-signed certs, NO shared + // CA, so peer traffic is encrypted but NOT authenticated. This is a + // migration-only knob etcd-migrate stamps when adopting a legacy cluster + // that ran the previous operator's unconditional --peer-auto-tls default + // (no CA exists to do real mTLS, so a strict-mTLS replacement could never + // rejoin the still-auto-tls members). Unlike AnnHeadlessServiceName / + // AnnDataDirSubPath it is cluster-level and does NOT self-wipe: the + // controller propagates it to every member it builds so replacement/ + // scaled members keep interoperating. Deliberately NOT a typed spec field + // — an unauthenticated peer plane must not be a discoverable, CEL-blessed + // option for new clusters; an undocumented reserved key is the lesser + // footgun. Superseded by an explicit spec.tls.peer.secretRef/certManager + // (real mTLS wins; precedence lives in clusterPeerAutoTLS). + AnnPeerAutoTLS = ReservedAnnotationPrefix + "peer-auto-tls" ) // etcdDataDirRoot is the mount path of every member's data volume; --data-dir @@ -136,14 +152,33 @@ func clusterClientScheme(cluster *lll.EtcdCluster) string { } // clusterPeerScheme returns "https" when the cluster has peer TLS configured, -// "http" otherwise. +// "http" otherwise. The legacy-compat --peer-auto-tls mode (carried on the +// AnnPeerAutoTLS annotation, no typed spec.tls.peer) also serves peer over +// https, so it counts too. func clusterPeerScheme(cluster *lll.EtcdCluster) string { if cluster != nil && cluster.Spec.TLS != nil && cluster.Spec.TLS.Peer != nil { return "https" } + if clusterPeerAutoTLS(cluster) { + return "https" + } return "http" } +// clusterPeerAutoTLS reports whether the cluster runs the legacy-compat +// --peer-auto-tls peer mode, carried on the reserved AnnPeerAutoTLS annotation +// (see its doc). An explicit typed peer TLS mode (secretRef/certManager) always +// wins, so the annotation is honoured only when spec.tls.peer is unset. +func clusterPeerAutoTLS(cluster *lll.EtcdCluster) bool { + if cluster == nil { + return false + } + if cluster.Spec.TLS != nil && cluster.Spec.TLS.Peer != nil { + return false + } + return cluster.Annotations[AnnPeerAutoTLS] == "true" +} + // memberClientScheme is the per-member counterpart to clusterClientScheme, // keyed off the propagated EtcdMemberSpec.TLS. func memberClientScheme(member *lll.EtcdMember) string { @@ -155,7 +190,8 @@ func memberClientScheme(member *lll.EtcdMember) string { // memberPeerScheme is the per-member counterpart to clusterPeerScheme. func memberPeerScheme(member *lll.EtcdMember) string { - if member != nil && member.Spec.TLS != nil && member.Spec.TLS.PeerSecretRef != nil { + if member != nil && member.Spec.TLS != nil && + (member.Spec.TLS.PeerSecretRef != nil || member.Spec.TLS.PeerAutoTLS) { return "https" } return "http" @@ -179,19 +215,27 @@ func buildInitialCluster(peerScheme string, names []string, service, namespace s // Secret names regardless of source, so buildPod / ensurePod / // buildOperatorTLSConfig stay source-agnostic. func deriveMemberTLS(cluster *lll.EtcdCluster) *lll.EtcdMemberTLS { - if cluster == nil || cluster.Spec.TLS == nil { - return nil - } - if cluster.Spec.TLS.Client == nil && cluster.Spec.TLS.Peer == nil { + if cluster == nil { return nil } out := &lll.EtcdMemberTLS{} - if name := serverSecretName(cluster); name != "" { - out.ClientServerSecretRef = &corev1.LocalObjectReference{Name: name} - out.ClientMTLS = operatorClientSecretName(cluster) != "" + if cluster.Spec.TLS != nil { + if name := serverSecretName(cluster); name != "" { + out.ClientServerSecretRef = &corev1.LocalObjectReference{Name: name} + out.ClientMTLS = operatorClientSecretName(cluster) != "" + } + if name := peerSecretName(cluster); name != "" { + out.PeerSecretRef = &corev1.LocalObjectReference{Name: name} + } + } + // Carry the legacy-compat --peer-auto-tls posture (a cluster-level + // reserved annotation, not typed spec) down to the member. clusterPeerAutoTLS + // already yields false when an explicit peer mode is set, so real mTLS wins. + if out.PeerSecretRef == nil && clusterPeerAutoTLS(cluster) { + out.PeerAutoTLS = true } - if name := peerSecretName(cluster); name != "" { - out.PeerSecretRef = &corev1.LocalObjectReference{Name: name} + if out.ClientServerSecretRef == nil && out.PeerSecretRef == nil && !out.PeerAutoTLS { + return nil } return out } diff --git a/docs/migration.md b/docs/migration.md index c4cc2277..7c48aa38 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -159,6 +159,47 @@ TLS caveat: the legacy API kept CAs in separate Secrets merge the CA into the referenced Secret **before** starting the new operator (with cert-manager-issued secrets, `ca.crt` is typically already in place). +### Peer auto-TLS (legacy `--peer-auto-tls`) + +The legacy operator ran etcd with `--peer-auto-tls` **unconditionally** unless +you supplied a BYO peer Secret. Under that flag each member generates its own +self-signed peer certificate and there is **no shared CA**: peer traffic is +encrypted but **not authenticated** — any TLS-capable workload that can reach a +member's `:2380` can peer with the cluster or impersonate a member. This is a +weaker posture than the real mutual-TLS the native operator offers via +`spec.tls.peer.secretRef` / `spec.tls.peer.certManager`, and it is **not** the +same thing as the [SAN-coverage caveat](#endpoint-compatibility) above (that is +about explicit mTLS certs needing both DNS domains during rollover — a different +scenario; don't conflate them). + +The tool **detects this and carries it forward**, because it has to: with no CA +in existence there is nothing to mint real mTLS certs from, so a replacement or +scaled-up member running strict mTLS (or plaintext peer) could never rejoin the +still-auto-tls members. Carry-forward keeps replacement/scale working. + +It is **not** exposed as a typed spec field — an unauthenticated peer plane must +not be a discoverable, first-class option for new clusters. Instead the tool +stamps a reserved cluster annotation: + +```yaml +metadata: + annotations: + etcd-operator.cozystack.io/peer-auto-tls: "true" +``` + +The operator reads it and propagates `--peer-auto-tls` to every member it builds +for that cluster. It is superseded by an explicit `spec.tls.peer.secretRef` / +`certManager` (real mTLS always wins). The dry-run plan flags the adoption with a +loud `⚠️ SECURITY:` line, and the post-`--apply` summary re-surfaces it — you +cannot complete a migration without being told you adopted an unauthenticated +peer plane. + +**The only off-ramp to real mTLS is delete-and-recreate** (`spec.tls` is +immutable), or a careful manual rolling restart onto BYO/cert-manager peer +certs. Because strict-mTLS and auto-tls members **cannot peer with each other**, +either route has a brief no-quorum window at the cutover — plan it like any +peer-cert rotation. + ### The safety backup Adoption rewires ownership of live storage, so the tool snapshots every diff --git a/internal/migrate/adopt.go b/internal/migrate/adopt.go index 20c672dc..66deed13 100644 --- a/internal/migrate/adopt.go +++ b/internal/migrate/adopt.go @@ -185,6 +185,35 @@ func BuildAdoption(name, namespace string, spec legacy.EtcdClusterSpec, facts Cl } } + // Detect the legacy operator's default --peer-auto-tls. It enables peer TLS + // with self-signed, no-shared-CA certs UNCONDITIONALLY unless a BYO + // peerSecret is set, so a default cluster advertises https:// peer URLs that + // translateTLS — which sees only the spec — cannot represent (it leaves + // spec.tls.peer nil). Carry it forward as the reserved cluster annotation + // AnnPeerAutoTLS (NOT a typed spec field — an unauthenticated peer plane + // must not be a discoverable option) so the new operator runs replacement/ + // scaled members with --peer-auto-tls too and they interoperate with the + // still-auto-tls adopted members (no shared CA exists to do real mTLS, and a + // plaintext-peer replacement could never join). This preserves the legacy + // peer security posture — encrypted but NOT authenticated — so flag it as a + // SecurityWarning; moving to real mTLS later is a delete-and-recreate. + peerTLSDeclared := cluster.Spec.TLS != nil && cluster.Spec.TLS.Peer != nil + if !peerTLSDeclared { + for _, m := range members { + if strings.HasPrefix(m.PeerURL, "https://") { + if cluster.Annotations == nil { + cluster.Annotations = map[string]string{} + } + cluster.Annotations[controllers.AnnPeerAutoTLS] = "true" + plan.SecurityWarnings = append(plan.SecurityWarnings, fmt.Sprintf( + "cluster runs etcd --peer-auto-tls (member %q advertises %s; no peerSecret in the legacy spec): carried forward via the reserved %s annotation so members keep interoperating across replacement/scale. "+ + "The peer plane is encrypted but NOT authenticated (no shared CA) — any TLS-capable workload that reaches :2380 can peer. Move to real mTLS (spec.tls.peer.secretRef or certManager) when you can; that is a delete-and-recreate since spec.tls is immutable.", + m.Name, m.PeerURL, controllers.AnnPeerAutoTLS)) + break + } + } + } + // Replicas follow the LIVE member count. A legacy spec disagreeing with // reality (mid-scale crash, manual edits) is surfaced, not silently // trusted — adopting with spec.replicas != len(members) would make the @@ -300,21 +329,25 @@ func BuildAdoption(name, namespace string, spec legacy.EtcdClusterSpec, facts Cl } // deriveAdoptedMemberTLS mirrors the controller's cluster→member TLS -// projection for the BYO-secret mode (the only mode a legacy translation -// produces): server secret ref + the "operator presents a client cert" bit, -// peer secret ref. +// projection for the modes a legacy translation produces: client server +// secret ref + the "operator presents a client cert" bit, and peer either as +// a secret ref (BYO) or auto-tls (legacy --peer-auto-tls, carried forward via +// the reserved AnnPeerAutoTLS cluster annotation rather than the typed spec). func deriveAdoptedMemberTLS(cluster *lll.EtcdCluster) *lll.EtcdMemberTLS { tls := cluster.Spec.TLS - if tls == nil || (tls.Client == nil && tls.Peer == nil) { + peerAutoTLS := cluster.Annotations[controllers.AnnPeerAutoTLS] == "true" + if (tls == nil || (tls.Client == nil && tls.Peer == nil)) && !peerAutoTLS { return nil } out := &lll.EtcdMemberTLS{} - if tls.Client != nil && tls.Client.ServerSecretRef != nil { + if tls != nil && tls.Client != nil && tls.Client.ServerSecretRef != nil { out.ClientServerSecretRef = &corev1.LocalObjectReference{Name: tls.Client.ServerSecretRef.Name} out.ClientMTLS = tls.Client.OperatorClientSecretRef != nil } - if tls.Peer != nil && tls.Peer.SecretRef != nil { + if tls != nil && tls.Peer != nil && tls.Peer.SecretRef != nil { out.PeerSecretRef = &corev1.LocalObjectReference{Name: tls.Peer.SecretRef.Name} + } else if peerAutoTLS { + out.PeerAutoTLS = true } return out } diff --git a/internal/migrate/adopt_test.go b/internal/migrate/adopt_test.go index 6c943087..3ab82b5d 100644 --- a/internal/migrate/adopt_test.go +++ b/internal/migrate/adopt_test.go @@ -138,6 +138,64 @@ func TestBuildAdoption_MirrorsTLSOntoMembers(t *testing.T) { } } +// TestBuildAdoption_PeerAutoTLS: a cluster on the legacy default --peer-auto-tls +// (https peer URLs, no peerSecret in the spec) is carried forward via the +// reserved AnnPeerAutoTLS cluster annotation (NOT a typed spec field) AND raised +// as a SecurityWarning; the adopted members mirror PeerAutoTLS. With BYO peer +// TLS, the annotation is NOT set and the secret is carried into +// spec.tls.peer.secretRef instead. +func TestBuildAdoption_PeerAutoTLS(t *testing.T) { + hasAutoTLSWarn := func(p ResourcePlan) bool { + for _, w := range p.SecurityWarnings { + if strings.Contains(w, "peer-auto-tls") { + return true + } + } + return false + } + + // auto-tls: facts advertise https peer URLs, spec sets no peerSecret → + // AnnPeerAutoTLS annotation set + security warning + members mirror it. + autoPlan := BuildAdoption("etcd", "ns", adoptSpecFixture(t), adoptFactsFixture(3), TranslateOptions{}) + if autoPlan.Action != ActionAdopt { + t.Fatalf("auto-tls Action = %s (errors %v)", autoPlan.Action, autoPlan.Errors) + } + if !hasAutoTLSWarn(autoPlan) { + t.Errorf("expected a --peer-auto-tls security warning; SecurityWarnings: %v", autoPlan.SecurityWarnings) + } + ac := autoPlan.Target.(*lll.EtcdCluster) + if ac.Annotations[controllers.AnnPeerAutoTLS] != "true" { + t.Fatalf("auto-tls cluster must carry %s=true; got annotations %+v", controllers.AnnPeerAutoTLS, ac.Annotations) + } + if ac.Spec.TLS != nil && ac.Spec.TLS.Peer != nil { + t.Errorf("auto-tls must NOT set a typed spec.tls.peer; got %+v", ac.Spec.TLS.Peer) + } + for _, ma := range autoPlan.Adoption.Members { + if ma.Member.Spec.TLS == nil || !ma.Member.Spec.TLS.PeerAutoTLS { + t.Errorf("member %q must mirror PeerAutoTLS; got %+v", ma.Member.Name, ma.Member.Spec.TLS) + } + } + + // BYO peer TLS: peerSecret set → translateTLS carries spec.tls.peer, no annotation/warning. + spec := adoptSpecFixture(t) + spec.Security = &legacy.SecuritySpec{TLS: legacy.TLSSpec{PeerSecret: "peer", PeerTrustedCASecret: "peer"}} + byoPlan := BuildAdoption("etcd", "ns", spec, adoptFactsFixture(3), TranslateOptions{}) + if byoPlan.Action != ActionAdopt { + t.Fatalf("BYO Action = %s (errors %v)", byoPlan.Action, byoPlan.Errors) + } + if hasAutoTLSWarn(byoPlan) { + t.Errorf("BYO peer TLS must not trigger the auto-tls warning; SecurityWarnings: %v", byoPlan.SecurityWarnings) + } + cluster := byoPlan.Target.(*lll.EtcdCluster) + if cluster.Spec.TLS == nil || cluster.Spec.TLS.Peer == nil || + cluster.Spec.TLS.Peer.SecretRef == nil || cluster.Spec.TLS.Peer.SecretRef.Name != "peer" { + t.Errorf("BYO peer TLS must be carried into spec.tls.peer.secretRef; got %+v", cluster.Spec.TLS) + } + if cluster.Annotations[controllers.AnnPeerAutoTLS] != "" { + t.Errorf("BYO peer TLS must not set the peer-auto-tls annotation; got %+v", cluster.Annotations) + } +} + // TestBuildAdoption_Refusals pins the states the tool must not touch. func TestBuildAdoption_Refusals(t *testing.T) { t.Run("learner member", func(t *testing.T) { diff --git a/internal/migrate/plan.go b/internal/migrate/plan.go index 38e2b9d6..3eb7532a 100644 --- a/internal/migrate/plan.go +++ b/internal/migrate/plan.go @@ -82,6 +82,12 @@ type ResourcePlan struct { // Warnings list legacy settings that do not carry over (dropped fields, // manual follow-ups like merging CA bundles into secrets). Warnings []string + // SecurityWarnings call out security-posture downgrades the adoption + // carries forward (e.g. an unauthenticated --peer-auto-tls peer plane). + // Rendered with a loud marker in the plan AND re-surfaced in the + // post-apply summary, so a migration cannot complete without the operator + // seeing them — distinct from routine Warnings, which scroll past easily. + SecurityWarnings []string // Errors explain why Action == ActionError. Errors []string // Notes are informational (endpoint compatibility, auth follow-ups).