Skip to content

Commit 2fe7a9f

Browse files
authored
feat(restore): run a version-matched etcdutl per target etcd version (#360)
2 parents c05e754 + 5fc57aa commit 2fe7a9f

14 files changed

Lines changed: 509 additions & 196 deletions

File tree

api/v1alpha2/etcdmember_types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ type EtcdMemberSpec struct {
174174

175175
// Restore is set only on the bootstrap seed when the parent cluster's
176176
// spec.bootstrap.restore is configured. It causes the member controller
177-
// to run a restore initContainer that populates the data dir from the
177+
// to run restore initContainers that populate the data dir from the
178178
// snapshot before etcd starts. Inert once the data dir is initialized.
179179
// +optional
180180
Restore *RestoreSpec `json:"restore,omitempty"`

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1177,7 +1177,7 @@ spec:
11771177
description: |-
11781178
Restore is set only on the bootstrap seed when the parent cluster's
11791179
spec.bootstrap.restore is configured. It causes the member controller
1180-
to run a restore initContainer that populates the data dir from the
1180+
to run restore initContainers that populate the data dir from the
11811181
snapshot before etcd starts. Inert once the data dir is initialized.
11821182
properties:
11831183
source:

charts/etcd-operator/templates/_helpers.tpl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ would hand those permissions to every workload using the default SA. */ -}}
5252
{{/*
5353
Full operator image reference. Used for BOTH the manager container image and
5454
its OPERATOR_IMAGE env var — they MUST be identical, or the operator refuses to
55-
start (the snapshot/restore agent runs this same image).
55+
start (the snapshot Job and the restore seed's install-tools initContainer run
56+
this same image).
5657
*/}}
5758
{{- define "etcd-operator.image" -}}
5859
{{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) -}}

controllers/etcdmember_controller.go

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -778,18 +778,18 @@ func (r *EtcdMemberReconciler) buildPod(member *lll.EtcdMember, clusterFormed bo
778778
})
779779
}
780780

781-
// Restore initContainer: when the seed carries a restore spec, populate
781+
etcdImage := resolveEtcdImage(member, r.EtcdImageRepository)
782+
783+
// Restore initContainers: when the seed carries a restore spec, populate
782784
// the data dir from the snapshot before etcd starts. The agent no-ops if
783785
// the data dir is already initialized, so it's safe across Pod restarts.
784786
var initContainers []corev1.Container
785787
if member.Spec.Restore != nil {
786-
ic, extraVols := restoreInitContainer(member, pAddr, r.OperatorImage)
787-
initContainers = append(initContainers, ic)
788+
ics, extraVols := restoreInitContainers(member, pAddr, r.OperatorImage, etcdImage)
789+
initContainers = append(initContainers, ics...)
788790
volumes = append(volumes, extraVols...)
789791
}
790792

791-
etcdImage := resolveEtcdImage(member, r.EtcdImageRepository)
792-
793793
return &corev1.Pod{
794794
ObjectMeta: metav1.ObjectMeta{
795795
Name: member.Name,
@@ -870,26 +870,39 @@ func (r *EtcdMemberReconciler) buildPod(member *lll.EtcdMember, clusterFormed bo
870870

871871
const restoreSrcMountPath = "/restore/src"
872872

873-
// restoreInitContainer builds the initContainer that restores the data dir
874-
// from a snapshot before etcd starts. peerAddr is this member's peer URL; the
875-
// agent feeds it (with the member name / initial-cluster / token) to etcdutl
876-
// so the restored data matches the identity the etcd container will run with.
877-
// For an S3 source the object key is exact (not a prefix); for a PVC source
878-
// the volume is mounted read-only and PVC_SUBPATH points to the snapshot file.
879-
func restoreInitContainer(member *lll.EtcdMember, peerAddr, operatorImage string) (corev1.Container, []corev1.Volume) {
873+
// restore-tools carries the operator binary from install-tools to the restore
874+
// container, which runs the etcd image (for its version-matched etcdutl).
875+
const (
876+
restoreToolsVolumeName = "restore-tools"
877+
restoreToolsMountPath = "/tools"
878+
)
879+
880+
// restoreInitContainers builds the ordered initContainers that restore the data
881+
// dir before etcd starts. The rebuild runs a version-matched etcdutl by running
882+
// the agent from the target etcd image; that image can't copy etcdutl out, so
883+
// install-tools first stages the operator binary onto a shared volume for the
884+
// restore container to exec. peerAddr is this member's peer URL, fed (with
885+
// member name / initial-cluster / token) to etcdutl so the restored data matches
886+
// the identity the etcd container runs with. For an S3 source the object key is
887+
// exact (not a prefix); for a PVC source the volume is mounted read-only and
888+
// PVC_SUBPATH points to the snapshot file.
889+
func restoreInitContainers(member *lll.EtcdMember, peerAddr, operatorImage, etcdImage string) ([]corev1.Container, []corev1.Volume) {
880890
src := member.Spec.Restore.Source
881891
env := []corev1.EnvVar{
882892
{Name: "ETCD_DATA_DIR", Value: "/var/lib/etcd"},
883893
{Name: "ETCD_MEMBER_NAME", Value: member.Name},
884894
{Name: "ETCD_INITIAL_CLUSTER", Value: member.Spec.InitialCluster},
885895
{Name: "ETCD_INITIAL_CLUSTER_TOKEN", Value: member.Spec.ClusterToken},
886896
{Name: "ETCD_PEER_URLS", Value: peerAddr},
887-
// The cluster's etcd version, for the agent's version-compat pre-flight
888-
// (the restored data dir must match the etcd that boots on it).
889-
{Name: "ETCD_VERSION", Value: member.Spec.Version},
890897
}
891-
mounts := []corev1.VolumeMount{{Name: "data", MountPath: "/var/lib/etcd"}}
892-
var vols []corev1.Volume
898+
mounts := []corev1.VolumeMount{
899+
{Name: "data", MountPath: "/var/lib/etcd"},
900+
{Name: restoreToolsVolumeName, MountPath: restoreToolsMountPath, ReadOnly: true},
901+
}
902+
vols := []corev1.Volume{{
903+
Name: restoreToolsVolumeName,
904+
VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}},
905+
}}
893906

894907
switch {
895908
case src.S3 != nil:
@@ -924,18 +937,38 @@ func restoreInitContainer(member *lll.EtcdMember, peerAddr, operatorImage string
924937
mounts = append(mounts, corev1.VolumeMount{Name: "restore-src", MountPath: restoreSrcMountPath, ReadOnly: true})
925938
}
926939

927-
return corev1.Container{
928-
Name: "restore",
929-
Image: operatorImage,
930-
Command: []string{"/manager", "restore-agent"},
931-
Env: env,
932-
SecurityContext: &corev1.SecurityContext{
940+
// A fresh SecurityContext per container — not one pointer shared by both —
941+
// so a later edit to one can't silently mutate the other.
942+
restrictedSecurityContext := func() *corev1.SecurityContext {
943+
return &corev1.SecurityContext{
933944
AllowPrivilegeEscalation: ptrBool(false),
934945
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
946+
}
947+
}
948+
949+
installTools := corev1.Container{
950+
Name: "install-tools",
951+
Image: operatorImage,
952+
Command: []string{"/manager", "install-tools"},
953+
Env: []corev1.EnvVar{{Name: "TOOLS_DEST_DIR", Value: restoreToolsMountPath}},
954+
SecurityContext: restrictedSecurityContext(),
955+
VolumeMounts: []corev1.VolumeMount{
956+
{Name: restoreToolsVolumeName, MountPath: restoreToolsMountPath},
935957
},
936-
VolumeMounts: mounts,
937-
Resources: restoreAgentResources(),
938-
}, vols
958+
Resources: restoreAgentResources(),
959+
}
960+
961+
restore := corev1.Container{
962+
Name: "restore",
963+
Image: etcdImage,
964+
Command: []string{restoreToolsMountPath + "/manager", "restore-agent"},
965+
Env: env,
966+
SecurityContext: restrictedSecurityContext(),
967+
VolumeMounts: mounts,
968+
Resources: restoreAgentResources(),
969+
}
970+
971+
return []corev1.Container{installTools, restore}, vols
939972
}
940973

941974
// dataLossRestartThreshold is how many times the etcd container must have

controllers/restore_initcontainer_test.go

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,44 @@ func findInitContainer(pod *corev1.Pod, name string) (corev1.Container, bool) {
4545
return corev1.Container{}, false
4646
}
4747

48+
func initContainerNames(pod *corev1.Pod) []string {
49+
names := make([]string, len(pod.Spec.InitContainers))
50+
for i, ic := range pod.Spec.InitContainers {
51+
names[i] = ic.Name
52+
}
53+
return names
54+
}
55+
56+
// The restore container's image must track spec.version exactly — the property
57+
// the whole feature rests on. Asserting it at a single version elsewhere does
58+
// not prove it varies with the version.
59+
func TestBuildPod_RestoreImageTracksVersion(t *testing.T) {
60+
r := &EtcdMemberReconciler{Scheme: testScheme(t), OperatorImage: "operator:latest"}
61+
for _, version := range []string{"3.5.21", "3.6.11"} {
62+
m := seedMember(&lll.RestoreSpec{Source: lll.SnapshotLocation{
63+
PVC: &lll.PVCSnapshotLocation{ClaimName: "snap-pvc", SubPath: "b1.db"},
64+
}})
65+
m.Spec.Version = version
66+
pod := r.buildPod(m, false)
67+
ic, ok := findInitContainer(pod, "restore")
68+
if !ok {
69+
t.Fatalf("version %s: restore initContainer missing", version)
70+
}
71+
if want := "quay.io/coreos/etcd:v" + version; ic.Image != want {
72+
t.Errorf("version %s: restore image = %q, want %q", version, ic.Image, want)
73+
}
74+
}
75+
}
76+
4877
func TestBuildPod_NoRestoreInitContainerWithoutSpec(t *testing.T) {
4978
r := &EtcdMemberReconciler{Scheme: testScheme(t), OperatorImage: "operator:latest"}
5079
pod := r.buildPod(seedMember(nil), false)
5180
if _, ok := findInitContainer(pod, "restore"); ok {
5281
t.Error("restore initContainer present though no restore spec was set")
5382
}
83+
if _, ok := findInitContainer(pod, "install-tools"); ok {
84+
t.Error("install-tools initContainer present though no restore spec was set")
85+
}
5486
}
5587

5688
func TestBuildPod_RestoreInitContainerS3(t *testing.T) {
@@ -66,15 +98,48 @@ func TestBuildPod_RestoreInitContainerS3(t *testing.T) {
6698
r := &EtcdMemberReconciler{Scheme: testScheme(t), OperatorImage: "operator:latest"}
6799
pod := r.buildPod(seedMember(restore), false)
68100

101+
// install-tools stages the operator binary onto the shared volume so the
102+
// restore container (etcd image) can exec it.
103+
it, ok := findInitContainer(pod, "install-tools")
104+
if !ok {
105+
t.Fatal("install-tools initContainer missing")
106+
}
107+
if it.Image != "operator:latest" {
108+
t.Errorf("install-tools image = %q, want operator:latest", it.Image)
109+
}
110+
if got, want := it.Command, []string{"/manager", "install-tools"}; len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
111+
t.Errorf("install-tools command = %v, want %v", got, want)
112+
}
113+
if m, ok := mountByName(it.VolumeMounts, "restore-tools"); !ok || m.MountPath != "/tools" || m.ReadOnly {
114+
t.Errorf("install-tools restore-tools mount = %+v, want writable at /tools", m)
115+
}
116+
117+
// install-tools must precede restore: it stages the binary the restore
118+
// container execs, so the order is correctness-critical.
119+
if got := initContainerNames(pod); len(got) != 2 || got[0] != "install-tools" || got[1] != "restore" {
120+
t.Errorf("initContainer order = %v, want [install-tools restore]", got)
121+
}
122+
69123
ic, ok := findInitContainer(pod, "restore")
70124
if !ok {
71125
t.Fatal("restore initContainer missing")
72126
}
73-
if ic.Image != "operator:latest" {
74-
t.Errorf("image = %q, want operator:latest", ic.Image)
127+
// The restore container runs the target etcd image, so its bundled etcdutl
128+
// matches spec.version — the whole point of restoring per-version.
129+
if ic.Image != "quay.io/coreos/etcd:v3.6.4" {
130+
t.Errorf("restore image = %q, want quay.io/coreos/etcd:v3.6.4 (version-matched)", ic.Image)
131+
}
132+
// It execs the operator binary staged on the shared volume, not the etcd image's entrypoint.
133+
if got, want := ic.Command, []string{"/tools/manager", "restore-agent"}; len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
134+
t.Errorf("restore command = %v, want %v", got, want)
75135
}
76-
if got, want := ic.Command, []string{"/manager", "restore-agent"}; len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
77-
t.Errorf("command = %v, want %v", got, want)
136+
if m, ok := mountByName(ic.VolumeMounts, "restore-tools"); !ok || m.MountPath != "/tools" {
137+
t.Errorf("restore restore-tools mount = %+v, want /tools", m)
138+
}
139+
// Both containers mount restore-tools by name; the backing Volume must
140+
// actually exist, or the Pod is rejected at create and bootstrap bricks.
141+
if v, ok := volumeByName(pod.Spec.Volumes, "restore-tools"); !ok || v.EmptyDir == nil {
142+
t.Errorf("restore-tools volume = %+v, want an emptyDir", v)
78143
}
79144

80145
// Restore identity must match what the etcd container will run with.
@@ -91,11 +156,6 @@ func TestBuildPod_RestoreInitContainerS3(t *testing.T) {
91156
if vals["ETCD_DATA_DIR"] != "/var/lib/etcd" {
92157
t.Errorf("ETCD_DATA_DIR = %q, want /var/lib/etcd", vals["ETCD_DATA_DIR"])
93158
}
94-
// The cluster's etcd version must be passed for the agent's version-compat
95-
// pre-flight (the restored data dir must match the etcd that boots on it).
96-
if vals["ETCD_VERSION"] != "3.6.4" {
97-
t.Errorf("ETCD_VERSION = %q, want 3.6.4", vals["ETCD_VERSION"])
98-
}
99159
if vals["SNAPSHOT_DEST_KIND"] != "s3" || vals["S3_KEY"] != "snapshots/b1.db" {
100160
t.Errorf("s3 source env = %+v", vals)
101161
}

docs/concepts.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ All conditions carry `observedGeneration` so consumers can tell whether a condit
469469

470470
### Observed member version
471471

472-
`spec.version` is *intent* — it pins the image tag (`v<version>`) and drives the restore version-compat gate. What etcd is **actually running** is a separate, observed fact. Once a member's Pod is Ready, the member controller reads the running version from that member's own etcd endpoint (the Maintenance `Status` RPC) and records it in `EtcdMember.status.version` (surfaced as the `Running` print column). The read is best-effort: a dial or RPC failure leaves the previous value in place and never affects `Ready` — readiness stays driven by Pod readiness and member-ID discovery alone.
472+
`spec.version` is *intent* — it pins the image tag (`v<version>`), which is also the etcd image whose `etcdutl` the restore agent runs. What etcd is **actually running** is a separate, observed fact. Once a member's Pod is Ready, the member controller reads the running version from that member's own etcd endpoint (the Maintenance `Status` RPC) and records it in `EtcdMember.status.version` (surfaced as the `Running` print column). The read is best-effort: a dial or RPC failure leaves the previous value in place and never affects `Ready` — readiness stays driven by Pod readiness and member-ID discovery alone.
473473

474474
When the observed version diverges from the member's intended `spec.version`, the member surfaces `VersionDrifted=True/VersionMismatch`; when they agree it is `False/VersionMatched`; when intent is not yet known (`spec.version` empty) the condition is left unset. This condition is **informational only** — the operator does not act on it (it never keys reconciliation off the observed value). It exists so intent-vs-reality drift is *detectable rather than assumed*, which is the prerequisite for safely reconsidering a per-cluster image/version override.
475475

@@ -491,11 +491,11 @@ Snapshot integrity note: a `Maintenance.Snapshot` stream carries no appended has
491491

492492
### Restore (`spec.bootstrap.restore`)
493493

494-
Restore is a first-bootstrap-only path, not a controller that mutates a running cluster. When `spec.bootstrap.restore.source` is set, the cluster controller stamps the `RestoreSpec` onto the bootstrap **seed** `EtcdMember` (only the seed — scale-up members join the live cluster normally). The member controller's `buildPod` then prepends a `restore` init container (the operator image, `manager restore-agent`) that shares the etcd data volume. Before etcd starts, the agent fetches the snapshot (S3 download / PVC read) and runs `etcdutl` `snapshot.Restore` into the data dir, using the seed's exact identity — member name, `--initial-cluster`, cluster token, peer URL — so etcd accepts the rebuilt data dir.
494+
Restore is a first-bootstrap-only path, not a controller that mutates a running cluster. When `spec.bootstrap.restore.source` is set, the cluster controller stamps the `RestoreSpec` onto the bootstrap **seed** `EtcdMember` (only the seed — scale-up members join the live cluster normally). The member controller's `buildPod` then prepends two init containers that share the etcd data volume: `install-tools` (operator image) copies the operator binary onto a shared volume, and `restore` runs that binary (`manager restore-agent`) — but from the **target etcd image**, so it reaches the version-matched `etcdutl` bundled there. Before etcd starts, the agent fetches the snapshot (S3 download / PVC read) and execs `etcdutl snapshot restore` into the data dir, using the seed's exact identity — member name, `--initial-cluster`, cluster token, peer URL — so etcd accepts the rebuilt data dir.
495495

496496
The init container is idempotent: it no-ops if the data dir already contains a `member/` directory, so Pod restarts after first boot leave live data untouched and never re-download. Because `spec.bootstrap` is CEL-immutable post-create, the restore intent can't be added to or changed on a live cluster — restore happens once, at birth, or not at all. A restored cluster gets a fresh etcd cluster ID: it is a new cluster seeded with old data, not a continuation.
497497

498-
The rebuild uses the `etcdutl` vendored into the operator image, whose on-disk storage format is minor-version-specific. So restore requires `spec.version` to match that `etcdutl`'s minor (currently etcd **3.6.x**): the agent reads `spec.version` (passed as `ETCD_VERSION`) and **fails the restore early** with an actionable message if the major.minor differs, rather than rebuilding a data dir an older etcd would fail to boot. Restoring into a different minor means using an operator build whose `etcdutl` matches. (Non-restore clusters are unaffected — this gate only fires on the restore path.)
498+
The rebuild's `etcdutl` is the one bundled in the target etcd image (`v<spec.version>`), not one compiled into the operator. Since a data dir's on-disk storage format is minor-version-specific, running the `etcdutl` that ships with the very etcd that will boot on the result keeps the two in lockstep by construction — so restore works for any etcd version the operator supports, not only the operator's own minor. That lockstep is `etcdutl`↔etcd only: the snapshot's own origin version is neither recorded nor checked, so restoring a snapshot taken from a newer etcd into an older `spec.version` remains unsupported (see the [restore runbook](operations.md#restoring-a-cluster-from-a-snapshot)). The two init containers exist to bridge two distroless images that share no binaries: the etcd image has `etcdutl` but no way to copy it out, so `install-tools` brings the operator binary to the etcd image instead.
499499

500500
This idempotency relies on the data dir being **persistent**. Restore is therefore rejected (by CEL) together with `spec.storage.medium: Memory`: a tmpfs data dir is wiped on every Pod restart, which would defeat the `member/`-exists guard and silently re-restore the original snapshot — reverting any writes since the restore, or breaking a multi-member cluster whose other members already moved past the restored cluster ID. Restore onto memory-backed storage is unsupported; use a PVC-backed cluster.
501501

0 commit comments

Comments
 (0)