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/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_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) + } + }) + } +} 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/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 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