From 29d9bcd9cec447a261438e28dfbf85c4bb854f3d Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Tue, 20 Oct 2026 22:47:49 +0500 Subject: [PATCH 1/3] feat(api): add spec.imageRepository and spec.imageTagSuffix Per-cluster image settings for a cluster that runs from another repository or from an image published with a tag suffix, such as a FIPS build or an image that wraps etcd. The tag still begins with v: admission rejects a repository carrying its own tag or digest and a suffix that does not start with '-'. Assisted-by: LLM Signed-off-by: Kirill Ilin --- api/v1alpha2/etcdcluster_types.go | 33 +++++++ api/v1alpha2/etcdmember_types.go | 12 +++ ...cd-operator.cozystack.io_etcdclusters.yaml | 40 ++++++++ ...tcd-operator.cozystack.io_etcdmembers.yaml | 12 +++ controllers/image_envtest_test.go | 91 +++++++++++++++++++ 5 files changed, 188 insertions(+) create mode 100644 controllers/image_envtest_test.go diff --git a/api/v1alpha2/etcdcluster_types.go b/api/v1alpha2/etcdcluster_types.go index a57d32f2..370e2fca 100644 --- a/api/v1alpha2/etcdcluster_types.go +++ b/api/v1alpha2/etcdcluster_types.go @@ -605,6 +605,31 @@ type EtcdClusterSpec struct { // status.observed. // +optional ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` + + // ImageRepository overrides the operator-wide etcd image repository + // (--etcd-image-repository) for this cluster: registry host and path, + // without a tag or digest. The tag stays v plus + // ImageTagSuffix, so the image always runs the version the operator + // manages. + // + // Latched through status.observed and copied onto every member; a + // running member switches to it when its Pod is recreated. + // +kubebuilder:validation:MaxLength=255 + // +kubebuilder:validation:XValidation:rule="!self.contains('@') && !self.substring(self.lastIndexOf('/') + 1).contains(':')",message="spec.imageRepository is a repository without a tag or digest; the tag is v followed by spec.imageTagSuffix" + // +optional + ImageRepository string `json:"imageRepository,omitempty"` + + // ImageTagSuffix is appended to the v image tag, for + // images published with a suffix such as -fips or the version of an + // etcd wrapper. It starts with '-' so the tag still begins with the + // etcd version. + // + // Latched through status.observed and copied onto every member; a + // running member switches to it when its Pod is recreated. + // +kubebuilder:validation:MaxLength=100 + // +kubebuilder:validation:XValidation:rule="self.matches('^-[A-Za-z0-9_.-]+$')",message="spec.imageTagSuffix starts with '-' and contains only letters, digits, '_', '.' and '-', so the image tag still begins with v" + // +optional + ImageTagSuffix string `json:"imageTagSuffix,omitempty"` } // AdditionalMetadata is a set of labels and annotations the operator merges @@ -683,6 +708,14 @@ type ObservedClusterSpec struct { // reached. // +optional ExtraArgs map[string]string `json:"extraArgs,omitempty"` + + // ImageRepository is the locked target etcd image repository override. + // +optional + ImageRepository string `json:"imageRepository,omitempty"` + + // ImageTagSuffix is the locked target etcd image tag suffix. + // +optional + ImageTagSuffix string `json:"imageTagSuffix,omitempty"` } // EtcdClusterStatus defines the observed state of an etcd cluster. diff --git a/api/v1alpha2/etcdmember_types.go b/api/v1alpha2/etcdmember_types.go index bc8b6ebf..f59bd732 100644 --- a/api/v1alpha2/etcdmember_types.go +++ b/api/v1alpha2/etcdmember_types.go @@ -137,6 +137,18 @@ type EtcdMemberSpec struct { // +optional ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` + // ImageRepository mirrors the latched EtcdCluster.spec.imageRepository. + // The cluster controller copies it onto each member at creation and + // keeps it in sync afterwards; a running Pod picks a change up only + // when it is recreated. + // +optional + ImageRepository string `json:"imageRepository,omitempty"` + + // ImageTagSuffix mirrors the latched EtcdCluster.spec.imageTagSuffix, + // with the same sync rule as ImageRepository. + // +optional + ImageTagSuffix string `json:"imageTagSuffix,omitempty"` + // Bootstrap indicates this member is part of the initial cluster formation. // When true the member starts with --initial-cluster-state=new. // +optional diff --git a/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml b/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml index 727920c5..b6056b3d 100644 --- a/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml +++ b/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml @@ -1259,6 +1259,38 @@ spec: type: object x-kubernetes-map-type: atomic type: array + imageRepository: + description: |- + ImageRepository overrides the operator-wide etcd image repository + (--etcd-image-repository) for this cluster: registry host and path, + without a tag or digest. The tag stays v plus + ImageTagSuffix, so the image always runs the version the operator + manages. + + Latched through status.observed and copied onto every member; a + running member switches to it when its Pod is recreated. + maxLength: 255 + type: string + x-kubernetes-validations: + - message: spec.imageRepository is a repository without a tag or digest; + the tag is v followed by spec.imageTagSuffix + rule: '!self.contains(''@'') && !self.substring(self.lastIndexOf(''/'') + + 1).contains('':'')' + imageTagSuffix: + description: |- + ImageTagSuffix is appended to the v image tag, for + images published with a suffix such as -fips or the version of an + etcd wrapper. It starts with '-' so the tag still begins with the + etcd version. + + Latched through status.observed and copied onto every member; a + running member switches to it when its Pod is recreated. + maxLength: 100 + type: string + x-kubernetes-validations: + - message: spec.imageTagSuffix starts with '-' and contains only letters, + digits, '_', '.' and '-', so the image tag still begins with v + rule: self.matches('^-[A-Za-z0-9_.-]+$') options: description: |- Options carries etcd server tuning flags (backend quota, @@ -2978,6 +3010,14 @@ spec: type: object x-kubernetes-map-type: atomic type: array + imageRepository: + description: ImageRepository is the locked target etcd image repository + override. + type: string + imageTagSuffix: + description: ImageTagSuffix is the locked target etcd image tag + suffix. + type: string options: description: |- Options is the locked target etcd tuning flags for member Pods. 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 d1412872..122bfe81 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 @@ -1056,6 +1056,18 @@ spec: type: object x-kubernetes-map-type: atomic type: array + imageRepository: + description: |- + ImageRepository mirrors the latched EtcdCluster.spec.imageRepository. + The cluster controller copies it onto each member at creation and + keeps it in sync afterwards; a running Pod picks a change up only + when it is recreated. + type: string + imageTagSuffix: + description: |- + ImageTagSuffix mirrors the latched EtcdCluster.spec.imageTagSuffix, + with the same sync rule as ImageRepository. + type: string initialCluster: description: |- InitialCluster is the value passed to etcd's --initial-cluster flag. diff --git a/controllers/image_envtest_test.go b/controllers/image_envtest_test.go new file mode 100644 index 00000000..5877a455 --- /dev/null +++ b/controllers/image_envtest_test.go @@ -0,0 +1,91 @@ +package controllers + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + + lll "github.com/cozystack/etcd-operator/api/v1alpha2" +) + +// TestImageAdmission_Envtest drives spec.imageRepository and +// spec.imageTagSuffix through a real apiserver with the generated CRDs, so +// the CEL rules that keep the tag pinned to spec.version are exercised as +// shipped. `make test` sets KUBEBUILDER_ASSETS; without it the test skips. +func TestImageAdmission_Envtest(t *testing.T) { + if os.Getenv("KUBEBUILDER_ASSETS") == "" { + t.Skip("KUBEBUILDER_ASSETS not set; envtest harness not initialized") + } + env := &envtest.Environment{ + Scheme: testScheme(t), + CRDDirectoryPaths: []string{filepath.Join("..", "charts", "etcd-operator", "crd-bases")}, + ErrorIfCRDPathMissing: true, + } + cfg, err := env.Start() + if err != nil { + t.Fatalf("envtest start: %v", err) + } + t.Cleanup(func() { _ = env.Stop() }) + k8s, err := client.New(cfg, client.Options{Scheme: testScheme(t)}) + if err != nil { + t.Fatalf("client: %v", err) + } + ctx := context.Background() + + create := func(repo, suffix string) error { + c := &lll.EtcdCluster{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "image-", Namespace: "default"}, + Spec: lll.EtcdClusterSpec{ + Replicas: ptrInt32(3), + Version: "3.6.1", + Storage: lll.StorageSpec{Size: resource.MustParse("128Mi")}, + ImageRepository: repo, + ImageTagSuffix: suffix, + }, + } + err := k8s.Create(ctx, c) + if err == nil { + _ = k8s.Delete(ctx, c) + } + return err + } + + for _, tc := range []struct{ repo, suffix string }{ + {"ghcr.io/aenix-io/threek", ""}, + {"registry.internal:5000/mirror/etcd", ""}, + {"etcd", ""}, + {"", "-threek.0.1.0"}, + {"ghcr.io/aenix-io/threek", "-fips_1.2"}, + } { + t.Run("accepts "+tc.repo+" "+tc.suffix, func(t *testing.T) { + if err := create(tc.repo, tc.suffix); err != nil { + t.Fatalf("apiserver rejected repository %q suffix %q: %v", tc.repo, tc.suffix, err) + } + }) + } + + for _, repo := range []string{"ghcr.io/aenix-io/threek:v3.6.1", "registry.internal:5000/etcd:latest", "ghcr.io/aenix-io/threek@sha256:0123"} { + t.Run("rejects repository "+repo, func(t *testing.T) { + err := create(repo, "") + if err == nil || !strings.Contains(err.Error(), "spec.imageRepository is a repository without a tag or digest") { + t.Fatalf("repository %q: error = %v, want the repository rule", repo, err) + } + }) + } + + for _, suffix := range []string{"threek", "-", "-a/b", "-a:b", "-a b"} { + t.Run("rejects suffix "+suffix, func(t *testing.T) { + err := create("", suffix) + if err == nil || !strings.Contains(err.Error(), "spec.imageTagSuffix starts with '-'") { + t.Fatalf("suffix %q: error = %v, want the suffix rule", suffix, err) + } + }) + } +} From d3de06487e8c8a7bb62e9f363c761be874527e2f Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Tue, 20 Oct 2026 22:47:50 +0500 Subject: [PATCH 2/3] feat(controllers): resolve the etcd image from per-cluster settings The cluster's repository replaces the operator-wide default and its suffix follows the version in the tag, for the etcd container and the restore init container alike. Both settings are latched, stamped on new members and synced onto existing ones, so deleting Pods one at a time moves a running cluster onto the new image. Assisted-by: LLM Signed-off-by: Kirill Ilin --- controllers/etcdcluster_controller.go | 19 +++- controllers/helpers.go | 21 +++-- controllers/image_test.go | 130 ++++++++++++++++++++++++++ controllers/member_template_test.go | 3 + internal/migrate/adopt.go | 4 + 5 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 controllers/image_test.go diff --git a/controllers/etcdcluster_controller.go b/controllers/etcdcluster_controller.go index 96a19a1c..538c38bc 100644 --- a/controllers/etcdcluster_controller.go +++ b/controllers/etcdcluster_controller.go @@ -409,7 +409,8 @@ func (r *EtcdClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) // ── Member templates ───────────────────────────────────────────────────── // syncMemberTemplates copies the latched Pod-template fields (options, -// extraArgs, resources) onto existing members. Running Pods are left alone; +// extraArgs, resources, image repository and tag suffix) onto existing +// members. Running Pods are left alone; // a Pod recreated for any reason, including a deliberate delete to roll a // change, picks the new template up. Storage, version, scheduling and // metadata stay as they were at member creation: they are bound to the @@ -420,13 +421,17 @@ func (r *EtcdClusterReconciler) syncMemberTemplates(ctx context.Context, cluster m := &members[i] if equality.Semantic.DeepEqual(m.Spec.Options, o.Options) && equality.Semantic.DeepEqual(m.Spec.ExtraArgs, o.ExtraArgs) && - equality.Semantic.DeepEqual(m.Spec.Resources, o.Resources) { + equality.Semantic.DeepEqual(m.Spec.Resources, o.Resources) && + m.Spec.ImageRepository == o.ImageRepository && + m.Spec.ImageTagSuffix == o.ImageTagSuffix { continue } patch := client.MergeFrom(m.DeepCopy()) m.Spec.Options = o.Options.DeepCopy() m.Spec.ExtraArgs = maps.Clone(o.ExtraArgs) m.Spec.Resources = *o.Resources.DeepCopy() + m.Spec.ImageRepository = o.ImageRepository + m.Spec.ImageTagSuffix = o.ImageTagSuffix if err := r.Patch(ctx, m, patch); err != nil { return fmt.Errorf("sync template of member %s: %w", m.Name, err) } @@ -488,6 +493,8 @@ func (r *EtcdClusterReconciler) bootstrap( Options: cluster.Status.Observed.Options, ExtraArgs: cluster.Status.Observed.ExtraArgs, ImagePullSecrets: cluster.Status.Observed.ImagePullSecrets, + ImageRepository: cluster.Status.Observed.ImageRepository, + ImageTagSuffix: cluster.Status.Observed.ImageTagSuffix, Bootstrap: true, ClusterToken: cluster.Status.ClusterToken, TLS: deriveMemberTLS(cluster), @@ -894,6 +901,8 @@ func (r *EtcdClusterReconciler) scaleUp( Options: cluster.Status.Observed.Options, ExtraArgs: cluster.Status.Observed.ExtraArgs, ImagePullSecrets: cluster.Status.Observed.ImagePullSecrets, + ImageRepository: cluster.Status.Observed.ImageRepository, + ImageTagSuffix: cluster.Status.Observed.ImageTagSuffix, Bootstrap: false, ClusterToken: cluster.Status.ClusterToken, TLS: deriveMemberTLS(cluster), @@ -2180,6 +2189,8 @@ func snapshotSpecIntoObserved(cluster *lll.EtcdCluster) { Options: cluster.Spec.Options, ImagePullSecrets: cluster.Spec.ImagePullSecrets, ExtraArgs: cluster.Spec.ExtraArgs, + ImageRepository: cluster.Spec.ImageRepository, + ImageTagSuffix: cluster.Spec.ImageTagSuffix, } } @@ -2202,7 +2213,9 @@ func specEqualsObserved(cluster *lll.EtcdCluster) bool { equality.Semantic.DeepEqual(o.AdditionalMetadata, cluster.Spec.AdditionalMetadata) && equality.Semantic.DeepEqual(o.Options, cluster.Spec.Options) && equality.Semantic.DeepEqual(o.ImagePullSecrets, cluster.Spec.ImagePullSecrets) && - equality.Semantic.DeepEqual(o.ExtraArgs, cluster.Spec.ExtraArgs) + equality.Semantic.DeepEqual(o.ExtraArgs, cluster.Spec.ExtraArgs) && + o.ImageRepository == cluster.Spec.ImageRepository && + o.ImageTagSuffix == cluster.Spec.ImageTagSuffix } // observedAdditionalMetadata returns the latched additionalMetadata target diff --git a/controllers/helpers.go b/controllers/helpers.go index 2792ad17..dc760ede 100644 --- a/controllers/helpers.go +++ b/controllers/helpers.go @@ -122,18 +122,23 @@ func memberDataDir(member *lll.EtcdMember) string { return path.Join(etcdDataDirRoot, sub) } -// resolveEtcdImage resolves a member's etcd container image from the -// operator-wide repository default (defaultRepo, from the operator's -// --etcd-image-repository flag) or the EtcdImage built-in when that is empty, -// tagged with "v"+spec.version. The operator keys every version-dependent -// behaviour off spec.version, so the image is always pinned to that version — -// there is no per-cluster tag override that could disagree with it. +// resolveEtcdImage resolves a member's etcd container image. The repository +// is the cluster's spec.imageRepository, else the operator-wide default +// (defaultRepo, from the operator's --etcd-image-repository flag), else the +// EtcdImage built-in. The tag is "v"+spec.version followed by the cluster's +// spec.imageTagSuffix. The operator keys every version-dependent behaviour +// off spec.version, so the tag always begins with that version — admission +// rejects a repository carrying its own tag and a suffix not starting with +// '-'. func resolveEtcdImage(member *lll.EtcdMember, defaultRepo string) string { - repo := defaultRepo + repo := member.Spec.ImageRepository + if repo == "" { + repo = defaultRepo + } if repo == "" { repo = EtcdImage } - return repo + ":v" + member.Spec.Version + return repo + ":v" + member.Spec.Version + member.Spec.ImageTagSuffix } // peerURL returns the etcd peer URL for a member, using the headless Service diff --git a/controllers/image_test.go b/controllers/image_test.go new file mode 100644 index 00000000..c24de25b --- /dev/null +++ b/controllers/image_test.go @@ -0,0 +1,130 @@ +package controllers + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + lll "github.com/cozystack/etcd-operator/api/v1alpha2" +) + +func TestResolveEtcdImage_ClusterOverrides(t *testing.T) { + member := func(repo, suffix string) *lll.EtcdMember { + return &lll.EtcdMember{Spec: lll.EtcdMemberSpec{Version: "3.6.1", ImageRepository: repo, ImageTagSuffix: suffix}} + } + cases := []struct { + name string + member *lll.EtcdMember + defaultRepo string + want string + }{ + {"cluster repository wins over the operator default", member("ghcr.io/aenix-io/threek", ""), "registry.internal/mirror/etcd", "ghcr.io/aenix-io/threek:v3.6.1"}, + {"cluster repository without an operator default", member("ghcr.io/aenix-io/threek", ""), "", "ghcr.io/aenix-io/threek:v3.6.1"}, + {"suffix after the version", member("", "-threek.0.1.0"), "", EtcdImage + ":v3.6.1-threek.0.1.0"}, + {"suffix on the operator default", member("", "-fips"), "registry.internal/mirror/etcd", "registry.internal/mirror/etcd:v3.6.1-fips"}, + {"repository and suffix", member("ghcr.io/aenix-io/threek", "-threek.0.1.0"), "registry.internal/mirror/etcd", "ghcr.io/aenix-io/threek:v3.6.1-threek.0.1.0"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resolveEtcdImage(tc.member, tc.defaultRepo); got != tc.want { + t.Errorf("image = %q, want %q", got, tc.want) + } + }) + } +} + +// The restore init container runs from the etcd image too, so it must follow +// the cluster's image as well. +func TestBuildPod_ClusterImageForEtcdAndRestore(t *testing.T) { + r := &EtcdMemberReconciler{Scheme: testScheme(t), OperatorImage: "operator:latest", EtcdImageRepository: "registry.internal/mirror/etcd"} + m := seedMember(&lll.RestoreSpec{Source: lll.SnapshotLocation{ + PVC: &lll.PVCSnapshotLocation{ClaimName: "snap-pvc", SubPath: "b1.db"}, + }}) + m.Spec.ImageRepository = "ghcr.io/aenix-io/threek" + m.Spec.ImageTagSuffix = "-threek.0.1.0" + pod := r.buildPod(m, false) + + want := "ghcr.io/aenix-io/threek:v3.6.4-threek.0.1.0" + if got := pod.Spec.Containers[0].Image; got != want { + t.Errorf("etcd image = %q, want %q", got, want) + } + ic, ok := findInitContainer(pod, "restore") + if !ok { + t.Fatalf("restore initContainer missing") + } + if ic.Image != want { + t.Errorf("restore image = %q, want %q", ic.Image, want) + } +} + +func TestSpecEqualsObserved_ImageMatters(t *testing.T) { + for name, set := range map[string]func(*lll.EtcdClusterSpec){ + "repository": func(s *lll.EtcdClusterSpec) { s.ImageRepository = "ghcr.io/aenix-io/threek" }, + "tag suffix": func(s *lll.EtcdClusterSpec) { s.ImageTagSuffix = "-threek.0.1.0" }, + } { + t.Run(name, func(t *testing.T) { + cluster := &lll.EtcdCluster{ + Spec: lll.EtcdClusterSpec{Replicas: ptrInt32(3), Version: "3.6.1", Storage: lll.StorageSpec{Size: quickQty(t, "1Gi")}}, + Status: lll.EtcdClusterStatus{Observed: &lll.ObservedClusterSpec{ + Replicas: 3, Version: "3.6.1", Storage: lll.StorageSpec{Size: quickQty(t, "1Gi")}, + }}, + } + set(&cluster.Spec) + if specEqualsObserved(cluster) { + t.Fatalf("specEqualsObserved must return false when the image %s differs", name) + } + snapshotSpecIntoObserved(cluster) + if !specEqualsObserved(cluster) { + t.Fatalf("specEqualsObserved must return true once the image %s is latched", name) + } + }) + } +} + +func TestBootstrap_SeedCarriesClusterImage(t *testing.T) { + cluster := &lll.EtcdCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, + Spec: lll.EtcdClusterSpec{ + Replicas: ptrInt32(3), Version: "3.6.1", Storage: lll.StorageSpec{Size: quickQty(t, "1Gi")}, + ImageRepository: "ghcr.io/aenix-io/threek", ImageTagSuffix: "-threek.0.1.0", + }, + } + c, _ := newTestClient(t, cluster) + r := &EtcdClusterReconciler{Client: c, Scheme: testScheme(t), EtcdClientFactory: factoryReturning(newFakeEtcd(0xdeadbeef))} + reconcileUntilStable(t, r, c, "test", "ns", 8) + + members := &lll.EtcdMemberList{} + if err := c.List(context.Background(), members, client.InNamespace("ns")); err != nil { + t.Fatal(err) + } + if len(members.Items) != 1 { + t.Fatalf("members = %d, want the seed only", len(members.Items)) + } + seed := members.Items[0] + if seed.Spec.ImageRepository != "ghcr.io/aenix-io/threek" || seed.Spec.ImageTagSuffix != "-threek.0.1.0" { + t.Errorf("seed image = %q + %q, want the cluster's", seed.Spec.ImageRepository, seed.Spec.ImageTagSuffix) + } +} + +// TestReconcile_SyncsClusterImageOntoExistingMembers: switching the image of +// a running cluster reaches every member, so deleting Pods one at a time +// rolls the cluster onto the new image. +func TestReconcile_SyncsClusterImageOntoExistingMembers(t *testing.T) { + observed := &lll.ObservedClusterSpec{ + Replicas: 3, Version: "3.5.17", Storage: lll.StorageSpec{Size: quickQty(t, "1Gi")}, + Options: &lll.EtcdOptions{SnapshotCount: ptrInt64(10000)}, + ExtraArgs: map[string]string{"log-level": "info"}, + ImageRepository: "ghcr.io/aenix-io/threek", + ImageTagSuffix: "-threek.0.1.0", + } + c, _ := newTestClient(t, steadyCluster(t, observed)...) + reconcileTest(t, c) + + m := mustGet(t, c, "test-1", "ns", &lll.EtcdMember{}) + pod := (&EtcdMemberReconciler{EtcdImageRepository: "registry.internal/mirror/etcd"}).buildPod(m, true) + if got, want := pod.Spec.Containers[0].Image, "ghcr.io/aenix-io/threek:v3.5.17-threek.0.1.0"; got != want { + t.Errorf("recreated Pod image = %q, want %q", got, want) + } +} diff --git a/controllers/member_template_test.go b/controllers/member_template_test.go index ba3b4b82..197d6199 100644 --- a/controllers/member_template_test.go +++ b/controllers/member_template_test.go @@ -32,6 +32,9 @@ func steadyCluster(t *testing.T, observed *lll.ObservedClusterSpec) []client.Obj Resources: observed.Resources, Options: observed.Options, ExtraArgs: observed.ExtraArgs, + + ImageRepository: observed.ImageRepository, + ImageTagSuffix: observed.ImageTagSuffix, } objs := []client.Object{&lll.EtcdCluster{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, diff --git a/internal/migrate/adopt.go b/internal/migrate/adopt.go index 538901ac..e460c1b6 100644 --- a/internal/migrate/adopt.go +++ b/internal/migrate/adopt.go @@ -281,6 +281,8 @@ func BuildAdoption(name, namespace string, spec legacy.EtcdClusterSpec, facts Cl Options: cluster.Spec.Options, ExtraArgs: cluster.Spec.ExtraArgs, ImagePullSecrets: cluster.Spec.ImagePullSecrets, + ImageRepository: cluster.Spec.ImageRepository, + ImageTagSuffix: cluster.Spec.ImageTagSuffix, Bootstrap: false, InitialCluster: initialCluster, ClusterToken: token, @@ -315,6 +317,8 @@ func BuildAdoption(name, namespace string, spec legacy.EtcdClusterSpec, facts Cl Options: cluster.Spec.Options, ExtraArgs: cluster.Spec.ExtraArgs, ImagePullSecrets: cluster.Spec.ImagePullSecrets, + ImageRepository: cluster.Spec.ImageRepository, + ImageTagSuffix: cluster.Spec.ImageTagSuffix, }, } plan.Adoption.StatefulSetName = name From 7220a9e61706db351edac07bf1225bee45df62d8 Mon Sep 17 00:00:00 2001 From: Kirill Ilin Date: Tue, 20 Oct 2026 22:47:50 +0500 Subject: [PATCH 3/3] docs: describe per-cluster image repository and tag suffix Assisted-by: LLM Signed-off-by: Kirill Ilin --- docs/installation.md | 3 ++- docs/migration.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 36a58c31..3793b6b1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -247,7 +247,8 @@ The `spec.tls` subtree is immutable post-create — flipping TLS on or off on an By default `spec.version` in an `EtcdCluster` becomes `quay.io/coreos/etcd:v`. For an air-gapped environment that mirrors the image to a private registry, repoint the **repository** operator-wide and supply per-cluster pull credentials: -- **Repository (operator-wide)** — set `etcdImage.repository` in the chart (env `ETCD_IMAGE_REPOSITORY` / flag `--etcd-image-repository`) to a registry/path, e.g. `registry.internal/mirror/etcd`. Every member Pod the operator creates pulls from it; the tag is always `v`. Restore init containers pull the same image (that is where the version-matched `etcdutl` comes from), so mirroring it also covers restore. Mirror once per fleet — there is intentionally no per-cluster repository/tag override, because the operator keys every version-dependent behaviour (the etcd image tag, the latched target, drift detection) off `spec.version`, and a per-cluster `tag` could silently disagree with it. +- **Repository (operator-wide)** — set `etcdImage.repository` in the chart (env `ETCD_IMAGE_REPOSITORY` / flag `--etcd-image-repository`) to a registry/path, e.g. `registry.internal/mirror/etcd`. Every member Pod the operator creates pulls from it; the tag is always `v`. Restore init containers pull the same image (that is where the version-matched `etcdutl` comes from), so mirroring it also covers restore. Mirror once per fleet. +- **Repository and tag suffix (per-cluster)** — `spec.imageRepository` replaces the operator-wide repository for one cluster, and `spec.imageTagSuffix` is appended to the tag, for images published as `v-` (a FIPS build, or an image that wraps etcd). There is still no free-form tag: the operator keys every version-dependent behaviour (the latched target, drift detection) off `spec.version`, so admission rejects a repository carrying its own tag or digest and a suffix that does not start with `-` — the tag always begins with `v`. Both fields are latched and copied onto every member; delete Pods one at a time to move a running cluster onto the new image. - **Pull credentials (per-cluster)** — `spec.imagePullSecrets` on an `EtcdCluster`: ```yaml diff --git a/docs/migration.md b/docs/migration.md index 55f8b878..abdf0b7a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -152,7 +152,8 @@ metadata) is reported as a warning — review them before `--apply`. (it used to be dropped) — so an air-gapped cluster keeps its credentials to pull from its mirror after the operator rolls a replacement Pod. (The etcd image's registry/tag is not carried; repoint the mirror operator-wide via -`--etcd-image-repository`, since the operator pins the image to `spec.version`.) +`--etcd-image-repository`, or per cluster via `spec.imageRepository`, since +the operator pins the image to `spec.version`.) Hard blockers (`emptyDir` storage — nothing to adopt, an unparsable etcd image tag without `--version`, `enableAuth` without server TLS, a non-integer