Skip to content

Commit 562a61f

Browse files
committed
feat(main): add migration tool
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
1 parent fb7d3e4 commit 562a61f

37 files changed

Lines changed: 5019 additions & 161 deletions

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ build: manifests generate fmt vet ## Build manager binary.
7979
kubectl-etcd: fmt vet ## Build the kubectl-etcd plugin binary.
8080
go build -o bin/kubectl-etcd ./cmd/kubectl-etcd
8181

82+
.PHONY: etcd-migrate
83+
etcd-migrate: fmt vet ## Build the etcd-migrate (legacy v1alpha1 -> v1alpha2) CLI binary.
84+
go build -o bin/etcd-migrate ./cmd/etcd-migrate
85+
8286
.PHONY: run
8387
run: manifests generate fmt vet ## Run a controller from your host.
8488
go run ./main.go

api/v1alpha2/cel_validation_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,66 @@ func TestSchema_OptionsValidation(t *testing.T) {
655655
}
656656
}
657657

658+
// TestCEL_HeadlessServiceNameImmutable covers the adoption field's
659+
// lifecycle rules: valid on create, rejected on add, change and removal —
660+
// the name is baked into member identity (peer URLs persisted in etcd,
661+
// pod subdomains), so any post-create edit would desync DNS from etcd.
662+
func TestCEL_HeadlessServiceNameImmutable(t *testing.T) {
663+
skipIfNoEnvtest(t)
664+
ctx := context.Background()
665+
666+
// Valid on create (the in-place migration path).
667+
c := validCluster("hsn-create")
668+
c.Spec.HeadlessServiceName = "legacy-headless"
669+
if err := k8s.Create(ctx, c); err != nil {
670+
t.Fatalf("create with headlessServiceName: %v", err)
671+
}
672+
t.Cleanup(func() { _ = k8s.Delete(ctx, c) })
673+
674+
// Change rejected.
675+
got := &lll.EtcdCluster{}
676+
if err := k8s.Get(ctx, ctrlclient.ObjectKeyFromObject(c), got); err != nil {
677+
t.Fatalf("Get: %v", err)
678+
}
679+
got.Spec.HeadlessServiceName = "other"
680+
if err := k8s.Update(ctx, got); err == nil {
681+
t.Fatalf("apiserver accepted headlessServiceName change; expected rejection")
682+
} else if !strings.Contains(err.Error(), "immutable") {
683+
t.Fatalf("error did not mention immutability: %v", err)
684+
}
685+
686+
// Removal rejected.
687+
if err := k8s.Get(ctx, ctrlclient.ObjectKeyFromObject(c), got); err != nil {
688+
t.Fatalf("Get: %v", err)
689+
}
690+
got.Spec.HeadlessServiceName = ""
691+
if err := k8s.Update(ctx, got); err == nil {
692+
t.Fatalf("apiserver accepted headlessServiceName removal; expected rejection")
693+
}
694+
695+
// Add-on-existing rejected.
696+
plain := validCluster("hsn-add")
697+
if err := k8s.Create(ctx, plain); err != nil {
698+
t.Fatalf("create plain cluster: %v", err)
699+
}
700+
t.Cleanup(func() { _ = k8s.Delete(ctx, plain) })
701+
if err := k8s.Get(ctx, ctrlclient.ObjectKeyFromObject(plain), got); err != nil {
702+
t.Fatalf("Get: %v", err)
703+
}
704+
got.Spec.HeadlessServiceName = "late-override"
705+
if err := k8s.Update(ctx, got); err == nil {
706+
t.Fatalf("apiserver accepted adding headlessServiceName post-create; expected rejection")
707+
}
708+
709+
// DNS-label pattern enforced.
710+
bad := validCluster("hsn-bad")
711+
bad.Spec.HeadlessServiceName = "Not_A_DNS_Label"
712+
if err := k8s.Create(ctx, bad); err == nil {
713+
_ = k8s.Delete(ctx, bad)
714+
t.Fatalf("apiserver accepted invalid headlessServiceName; expected rejection")
715+
}
716+
}
717+
658718
// spec.auth.enabled requires spec.tls.client — auth credentials must
659719
// not cross a plaintext wire. This rule does not reference oldSelf, so it is
660720
// enforced on CREATE.

api/v1alpha2/etcdcluster_types.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,8 @@ type StorageSpec struct {
435435
// +kubebuilder:validation:XValidation:rule="!has(self.auth) || !has(oldSelf.auth) || self.auth == oldSelf.auth",message="spec.auth is immutable post-create; delete and recreate the cluster to change auth configuration"
436436
// +kubebuilder:validation:XValidation:rule="!(has(self.auth) && self.auth.enabled) || (has(self.tls) && has(self.tls.client))",message="spec.auth.enabled requires spec.tls.client (auth credentials must not cross a plaintext connection)"
437437
// +kubebuilder:validation:XValidation:rule="!(has(self.auth) && self.auth.enabled) || has(self.auth.rootCredentialsSecretRef)",message="spec.auth.enabled requires spec.auth.rootCredentialsSecretRef"
438+
// +kubebuilder:validation:XValidation:rule="has(self.headlessServiceName) == has(oldSelf.headlessServiceName)",message="spec.headlessServiceName cannot be added to or removed from an existing cluster; it is baked into member identity (peer URLs, pod subdomains)"
439+
// +kubebuilder:validation:XValidation:rule="!has(self.headlessServiceName) || !has(oldSelf.headlessServiceName) || self.headlessServiceName == oldSelf.headlessServiceName",message="spec.headlessServiceName is immutable post-create; it is baked into member identity (peer URLs, pod subdomains)"
438440
// +kubebuilder:validation:XValidation:rule="has(self.bootstrap) == has(oldSelf.bootstrap)",message="spec.bootstrap cannot be added to or removed from an existing cluster; it is consulted only at first bootstrap"
439441
// +kubebuilder:validation:XValidation:rule="!has(self.bootstrap) || !has(oldSelf.bootstrap) || self.bootstrap == oldSelf.bootstrap",message="spec.bootstrap is immutable post-create; it is consulted only at first bootstrap"
440442
// +kubebuilder:validation:XValidation:rule="!(has(self.bootstrap) && has(self.bootstrap.restore)) || !(has(self.storage) && has(self.storage.medium) && self.storage.medium == 'Memory')",message="spec.bootstrap.restore is unsupported with spec.storage.medium=Memory: the restored data dir is tmpfs, so any seed Pod restart re-restores the snapshot — reverting writes (single member) or breaking the cluster with a fresh ID it can't rejoin (multi-member). Use persistent storage to restore."
@@ -558,6 +560,27 @@ type EtcdClusterSpec struct {
558560
// tuning change in place.
559561
// +optional
560562
Options *EtcdOptions `json:"options,omitempty"`
563+
564+
// HeadlessServiceName overrides the name of the headless Service the
565+
// operator maintains for per-member DNS. Empty (the default) means
566+
// the Service is named after the cluster. The name is baked into
567+
// every member's identity — peer URLs persisted inside etcd, the
568+
// Pods' spec.subdomain, and every endpoint the operator dials — so
569+
// the field is immutable post-create (spec-level CEL, like
570+
// storageClassName).
571+
//
572+
// The main consumer is in-place migration from the legacy
573+
// etcd.aenix.io operator: its StatefulSet pods carry an immutable
574+
// spec.subdomain of "<cluster>-headless" and their peer URLs are
575+
// persisted in etcd under that DNS domain. Setting
576+
// headlessServiceName to the legacy Service name makes the operator's
577+
// constructed URLs match the adopted pods' actual DNS exactly, so
578+
// discovery, scale-up and replacement work without any
579+
// adopted-member special cases.
580+
// +kubebuilder:validation:MaxLength=63
581+
// +kubebuilder:validation:Pattern=`^[a-z]([-a-z0-9]*[a-z0-9])?$`
582+
// +optional
583+
HeadlessServiceName string `json:"headlessServiceName,omitempty"`
561584
}
562585

563586
// AdditionalMetadata is a set of labels and annotations the operator merges

api/v1alpha2/etcdmember_types.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,32 @@ type EtcdMemberSpec struct {
105105
// +optional
106106
Options *EtcdOptions `json:"options,omitempty"`
107107

108+
// HeadlessServiceName mirrors EtcdCluster.spec.headlessServiceName.
109+
// Empty means the headless Service is named after the cluster. The
110+
// member controller uses it for the Pod's spec.subdomain and for
111+
// every constructed peer/client URL, so the member's DNS identity
112+
// matches the cluster's headless Service.
113+
// +kubebuilder:validation:MaxLength=63
114+
// +kubebuilder:validation:Pattern=`^[a-z]([-a-z0-9]*[a-z0-9])?$`
115+
// +optional
116+
HeadlessServiceName string `json:"headlessServiceName,omitempty"`
117+
118+
// DataDirSubPath relocates etcd's --data-dir to a subdirectory of the
119+
// member's data volume: empty (the default) means the volume root
120+
// (/var/lib/etcd), a value V means /var/lib/etcd/V. A single path
121+
// component — no slashes — so it cannot escape the mount.
122+
//
123+
// Set by the in-place migration from the legacy etcd.aenix.io
124+
// operator, whose StatefulSet pods kept their data under the
125+
// "default.etcd" subdirectory of the PVC. With the subPath recorded
126+
// here, a replacement Pod built by this operator finds the existing
127+
// data dir and resumes with the member's persisted identity instead
128+
// of starting empty and crashlooping against the cluster.
129+
// +kubebuilder:validation:MaxLength=128
130+
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`
131+
// +optional
132+
DataDirSubPath string `json:"dataDirSubPath,omitempty"`
133+
108134
// Bootstrap indicates this member is part of the initial cluster formation.
109135
// When true the member starts with --initial-cluster-state=new.
110136
// +optional

0 commit comments

Comments
 (0)