Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ deploy: manifests require-helm ## Install/upgrade the operator (CRDs + RBAC + ma
img='$(IMG)'; $(HELM) upgrade --install $(HELM_RELEASE) charts/etcd-operator \
--namespace $(NAMESPACE) --create-namespace \
--set image.repository="$${img%:*}" --set image.tag="$${img##*:}" \
$(HELM_EXTRA_ARGS) \
--wait --timeout 5m

.PHONY: undeploy
Expand Down
16 changes: 16 additions & 0 deletions api/v1alpha2/etcdcluster_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,17 @@ type EtcdClusterSpec struct {
// tuning change in place.
// +optional
Options *EtcdOptions `json:"options,omitempty"`

// ImagePullSecrets is a list of Secret references in the cluster's
// namespace used to pull the etcd (and restore initContainer) image from
// a private registry — e.g. an air-gapped mirror behind credentials.
// Passed straight through to each member Pod's spec.imagePullSecrets.
//
// Changes take effect on newly-created members (scale-up, replacement);
// the operator does not roll existing Pods. Latched through
// status.observed.
// +optional
ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`
}

// AdditionalMetadata is a set of labels and annotations the operator merges
Expand Down Expand Up @@ -624,6 +635,11 @@ type ObservedClusterSpec struct {
// reached.
// +optional
Options *EtcdOptions `json:"options,omitempty"`

// ImagePullSecrets is the locked target pull-secret list for member
// Pods. Latched with the rest of the target spec.
// +optional
ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`
}

// EtcdClusterStatus defines the observed state of an etcd cluster.
Expand Down
6 changes: 6 additions & 0 deletions api/v1alpha2/etcdmember_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ type EtcdMemberSpec struct {
// +optional
Options *EtcdOptions `json:"options,omitempty"`

// ImagePullSecrets mirrors EtcdCluster.spec.imagePullSecrets at the time
// this member was created. Passed straight to the Pod's
// spec.imagePullSecrets at build time.
// +optional
ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`

// Bootstrap indicates this member is part of the initial cluster formation.
// When true the member starts with --initial-cluster-state=new.
// +optional
Expand Down
15 changes: 15 additions & 0 deletions api/v1alpha2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,33 @@ spec:
rule: '!has(self.source.pvc) || (has(self.source.pvc.subPath)
&& size(self.source.pvc.subPath) > 0)'
type: object
imagePullSecrets:
description: |-
ImagePullSecrets is a list of Secret references in the cluster's
namespace used to pull the etcd (and restore initContainer) image from
a private registry — e.g. an air-gapped mirror behind credentials.
Passed straight through to each member Pod's spec.imagePullSecrets.

Changes take effect on newly-created members (scale-up, replacement);
the operator does not roll existing Pods. Latched through
status.observed.
items:
description: |-
LocalObjectReference contains enough information to let you locate the
referenced object inside the same namespace.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
type: array
options:
description: |-
Options carries etcd server tuning flags (backend quota,
Expand Down Expand Up @@ -2856,6 +2883,27 @@ spec:
x-kubernetes-list-type: atomic
type: object
type: object
imagePullSecrets:
description: |-
ImagePullSecrets is the locked target pull-secret list for member
Pods. Latched with the rest of the target spec.
items:
description: |-
LocalObjectReference contains enough information to let you locate the
referenced object inside the same namespace.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
type: array
options:
description: |-
Options is the locked target etcd tuning flags for member Pods.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,28 @@ spec:
the same ClusterID and member ID. While dormant, the member does
not count toward the EtcdCluster's `current` replica accounting.
type: boolean
imagePullSecrets:
description: |-
ImagePullSecrets mirrors EtcdCluster.spec.imagePullSecrets at the time
this member was created. Passed straight to the Pod's
spec.imagePullSecrets at build time.
items:
description: |-
LocalObjectReference contains enough information to let you locate the
referenced object inside the same namespace.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
type: array
initialCluster:
description: |-
InitialCluster is the value passed to etcd's --initial-cluster flag.
Expand Down
6 changes: 6 additions & 0 deletions charts/etcd-operator/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ spec:
# is left at a placeholder.
- name: OPERATOR_IMAGE
value: {{ include "etcd-operator.image" . }}
# Operator-wide default etcd image repository for member Pods. Always
# set, so the operator's built-in fallback is only reached when the
# binary runs outside this chart. Repoint here for an air-gapped mirror;
# the tag is always v<spec.version>.
- name: ETCD_IMAGE_REPOSITORY
value: {{ .Values.etcdImage.repository | quote }}
livenessProbe:
httpGet:
path: /healthz
Expand Down
22 changes: 22 additions & 0 deletions charts/etcd-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ image:
# -- Image pull policy.
pullPolicy: IfNotPresent

# Operator-wide default for the etcd image that runs in member Pods (NOT the
# operator's own image above). Set the repository here to point every cluster
# at an air-gapped mirror once. Per-cluster pull credentials go on an
# EtcdCluster's spec.imagePullSecrets (a Secret in the cluster's own
# namespace), not here.
etcdImage:
# -- Default etcd image repository (registry host + path, no tag) for member
# Pods. Repoint at an air-gapped mirror, e.g. registry.internal/mirror/etcd.
# The chart always wires this into the operator's ETCD_IMAGE_REPOSITORY.
#
# Only the repository is configurable; the tag is always derived from each
# cluster's spec.version as "v<version>" (e.g. v3.6.11). There is no
# per-cluster repository/tag override — the operator keys all
# version-dependent behaviour off spec.version, so a separate tag could
# silently disagree with it.
#
# Keep this in sync with the controllers.EtcdImage constant — the operator's
# built-in fallback used only when this env is unset (outside the chart). The
# chart always sets ETCD_IMAGE_REPOSITORY, so a drift between the two is
# harmless here, but bump both together to keep the no-chart default honest.
repository: quay.io/coreos/etcd

# -- Number of operator replicas (leader election picks the active one).
replicaCount: 1

Expand Down
6 changes: 5 additions & 1 deletion controllers/etcdcluster_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ func (r *EtcdClusterReconciler) bootstrap(
Affinity: cluster.Status.Observed.Affinity,
TopologySpreadConstraints: cluster.Status.Observed.TopologySpreadConstraints,
Options: cluster.Status.Observed.Options,
ImagePullSecrets: cluster.Status.Observed.ImagePullSecrets,
Bootstrap: true,
ClusterToken: cluster.Status.ClusterToken,
TLS: deriveMemberTLS(cluster),
Expand Down Expand Up @@ -834,6 +835,7 @@ func (r *EtcdClusterReconciler) scaleUp(
Affinity: cluster.Status.Observed.Affinity,
TopologySpreadConstraints: cluster.Status.Observed.TopologySpreadConstraints,
Options: cluster.Status.Observed.Options,
ImagePullSecrets: cluster.Status.Observed.ImagePullSecrets,
Bootstrap: false,
ClusterToken: cluster.Status.ClusterToken,
TLS: deriveMemberTLS(cluster),
Expand Down Expand Up @@ -2028,6 +2030,7 @@ func snapshotSpecIntoObserved(cluster *lll.EtcdCluster) {
TopologySpreadConstraints: cluster.Spec.TopologySpreadConstraints,
AdditionalMetadata: cluster.Spec.AdditionalMetadata,
Options: cluster.Spec.Options,
ImagePullSecrets: cluster.Spec.ImagePullSecrets,
}
}

Expand All @@ -2048,7 +2051,8 @@ func specEqualsObserved(cluster *lll.EtcdCluster) bool {
equality.Semantic.DeepEqual(o.Affinity, cluster.Spec.Affinity) &&
equality.Semantic.DeepEqual(o.TopologySpreadConstraints, cluster.Spec.TopologySpreadConstraints) &&
equality.Semantic.DeepEqual(o.AdditionalMetadata, cluster.Spec.AdditionalMetadata) &&
equality.Semantic.DeepEqual(o.Options, cluster.Spec.Options)
equality.Semantic.DeepEqual(o.Options, cluster.Spec.Options) &&
equality.Semantic.DeepEqual(o.ImagePullSecrets, cluster.Spec.ImagePullSecrets)
}

// observedAdditionalMetadata returns the latched additionalMetadata target
Expand Down
12 changes: 11 additions & 1 deletion controllers/etcdmember_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ type EtcdMemberReconciler struct {
// OperatorImage is the operator's own image; the restore agent runs from
// it as an initContainer on the bootstrap seed Pod.
OperatorImage string

// EtcdImageRepository is the operator-wide default etcd image repository
// (registry host + path, no tag) used for member Pods whose EtcdCluster
// does not set spec.image.repository. Empty falls back to the EtcdImage
// built-in. Set from --etcd-image-repository / ETCD_IMAGE_REPOSITORY; the
// common use is pointing every cluster at an air-gapped mirror once.
EtcdImageRepository string
}

//+kubebuilder:rbac:groups=etcd-operator.cozystack.io,resources=etcdmembers,verbs=get;list;watch;update;patch
Expand Down Expand Up @@ -751,6 +758,8 @@ func (r *EtcdMemberReconciler) buildPod(member *lll.EtcdMember) *corev1.Pod {
volumes = append(volumes, extraVols...)
}

etcdImage := resolveEtcdImage(member, r.EtcdImageRepository)

return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: member.Name,
Expand All @@ -763,6 +772,7 @@ func (r *EtcdMemberReconciler) buildPod(member *lll.EtcdMember) *corev1.Pod {
Subdomain: memberServiceName(member),
Affinity: member.Spec.Affinity,
TopologySpreadConstraints: member.Spec.TopologySpreadConstraints,
ImagePullSecrets: member.Spec.ImagePullSecrets,
InitContainers: initContainers,
// etcd and the restore agent never call the Kubernetes API, so
// don't mount a ServiceAccount token into the member Pod (matches
Expand All @@ -779,7 +789,7 @@ func (r *EtcdMemberReconciler) buildPod(member *lll.EtcdMember) *corev1.Pod {
},
Containers: []corev1.Container{{
Name: "etcd",
Image: fmt.Sprintf("%s:v%s", EtcdImage, member.Spec.Version),
Image: etcdImage,
SecurityContext: &corev1.SecurityContext{
AllowPrivilegeEscalation: ptrBool(false),
Capabilities: &corev1.Capabilities{
Expand Down
31 changes: 31 additions & 0 deletions controllers/etcdmember_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,37 @@ func TestBuildPod_LivenessIsNotQuorumAware(t *testing.T) {
}
}

// TestBuildPod_ImageRepoAndPullSecrets covers the air-gap path: buildPod
// resolves the etcd image against the operator-wide default repository (pinned
// to spec.version) and stamps the member's imagePullSecrets onto the Pod.
func TestBuildPod_ImageRepoAndPullSecrets(t *testing.T) {
t.Run("operator default repo, version-derived tag", func(t *testing.T) {
r := &EtcdMemberReconciler{EtcdImageRepository: "registry.internal/mirror/etcd"}
pod := r.buildPod(&lll.EtcdMember{
ObjectMeta: metav1.ObjectMeta{Name: "test-0", Namespace: "ns"},
Spec: lll.EtcdMemberSpec{ClusterName: "test", Version: "3.6.11"},
})
if got := pod.Spec.Containers[0].Image; got != "registry.internal/mirror/etcd:v3.6.11" {
t.Errorf("image = %q, want operator-default mirror", got)
}
})

t.Run("pull secrets are stamped onto the Pod", func(t *testing.T) {
r := &EtcdMemberReconciler{EtcdImageRepository: "registry.internal/mirror/etcd"}
pod := r.buildPod(&lll.EtcdMember{
ObjectMeta: metav1.ObjectMeta{Name: "test-0", Namespace: "ns"},
Spec: lll.EtcdMemberSpec{
ClusterName: "test",
Version: "3.6.11",
ImagePullSecrets: []corev1.LocalObjectReference{{Name: "regcreds"}},
},
})
if len(pod.Spec.ImagePullSecrets) != 1 || pod.Spec.ImagePullSecrets[0].Name != "regcreds" {
t.Errorf("pod.imagePullSecrets = %+v, want [regcreds]", pod.Spec.ImagePullSecrets)
}
})
}

// TestBuildPod_AppliesSchedulingAndMetadata covers the additionalMetadata,
// affinity, and topologySpreadConstraints passthrough: buildPod must stamp
// the Pod with the member's scheduling fields and merge the extra
Expand Down
20 changes: 19 additions & 1 deletion controllers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ const (
LabelRole = "etcd-operator.cozystack.io/role"
RoleVoter = "voter"

// EtcdImage is the container image repository for etcd.
// EtcdImage is the built-in fallback etcd image repository (registry
// host + path, no tag). It is used when the operator-wide
// --etcd-image-repository / ETCD_IMAGE_REPOSITORY default is unset. See
// resolveEtcdImage. Keep in sync with the chart's etcdImage.repository
// default in charts/etcd-operator/values.yaml.
EtcdImage = "quay.io/coreos/etcd"

// MemberFinalizer is placed on EtcdMember resources to ensure
Expand Down Expand Up @@ -108,6 +112,20 @@ 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.
func resolveEtcdImage(member *lll.EtcdMember, defaultRepo string) string {
repo := defaultRepo
if repo == "" {
repo = EtcdImage
}
return repo + ":v" + member.Spec.Version
}

// peerURL returns the etcd peer URL for a member, using the headless Service
// DNS. `service` is the headless Service name the member resolves under —
// resolve it per-member via memberServiceName (the cluster's own name by
Expand Down
Loading
Loading