Skip to content

Commit 733b7d5

Browse files
committed
feat(main): add e2e with kamaji case
1 parent b417c71 commit 733b7d5

13 files changed

Lines changed: 1104 additions & 2 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: E2E
2+
3+
on:
4+
pull_request:
5+
branches: [ master ]
6+
push:
7+
tags: [ "v*" ]
8+
workflow_dispatch:
9+
10+
concurrency:
11+
group: e2e-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
kamaji-datastore:
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 45
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: actions/setup-go@v5
22+
with:
23+
go-version-file: go.mod
24+
cache: true
25+
26+
- name: e2e (kind + cert-manager + Kamaji)
27+
# Provisions an ephemeral kind cluster, installs cert-manager and
28+
# Kamaji at the versions pinned in hack/e2e.sh, builds the operator
29+
# from this checkout and runs test/e2e — the Kamaji DataStore
30+
# consumer scenario.
31+
run: make e2e
32+
33+
- name: dump cluster state on failure
34+
if: failure()
35+
run: |
36+
kubectl get etcdclusters,etcdmembers,pods,certificates,secrets -A || true
37+
kubectl get datastores,tenantcontrolplanes -A || true
38+
kubectl -n etcd-operator-system logs deploy/etcd-operator-controller-manager --tail=200 || true
39+
kubectl -n kamaji-system logs deploy/kamaji --tail=200 || true

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ vet: ## Run go vet against code.
6161
test: manifests generate fmt vet envtest ## Run tests.
6262
KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./... -coverprofile cover.out
6363

64+
.PHONY: test-e2e
65+
test-e2e: ## Run the e2e suite against the current kubeconfig context (expects cert-manager, Kamaji and the operator installed; see hack/e2e.sh).
66+
go test -tags e2e -count=1 ./test/e2e/ -v -timeout 30m
67+
68+
.PHONY: e2e
69+
e2e: ## Provision a kind cluster with cert-manager and Kamaji, deploy the operator, run the e2e suite. KEEP_CLUSTER=1 keeps the cluster for debugging.
70+
hack/e2e.sh
71+
6472
##@ Build
6573

6674
.PHONY: build

cmd/kubectl-etcd/helpers_test.go

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
package main
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"crypto/rand"
7+
"crypto/tls"
8+
"crypto/x509"
9+
"crypto/x509/pkix"
10+
"encoding/pem"
11+
"errors"
12+
"fmt"
13+
"math/big"
14+
"strings"
15+
"testing"
16+
"time"
17+
18+
corev1 "k8s.io/api/core/v1"
19+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
20+
"k8s.io/client-go/kubernetes/fake"
21+
)
22+
23+
// ── Blocker #2: a failed/never-ready port-forward must not hang ──────────────
24+
25+
func TestAwaitForward_Ready(t *testing.T) {
26+
ready := make(chan struct{}, 1)
27+
close(ready)
28+
if err := awaitForward(ready, make(chan error, 1), make(chan struct{}, 1), time.Second); err != nil {
29+
t.Fatalf("ready forward should succeed, got %v", err)
30+
}
31+
}
32+
33+
func TestAwaitForward_ErrorBeforeReady(t *testing.T) {
34+
// ForwardPorts returns an error without ever closing readyChan — the old
35+
// code blocked on <-readyChan forever. awaitForward must return the error.
36+
forwardErr := make(chan error, 1)
37+
forwardErr <- fmt.Errorf("dial tcp: connection refused")
38+
err := awaitForward(make(chan struct{}), forwardErr, make(chan struct{}, 1), time.Second)
39+
if err == nil {
40+
t.Fatal("a forward failure must return an error, not hang or succeed")
41+
}
42+
if !strings.Contains(err.Error(), "connection refused") {
43+
t.Errorf("error should wrap the forward failure, got %v", err)
44+
}
45+
}
46+
47+
func TestAwaitForward_Timeout(t *testing.T) {
48+
stop := make(chan struct{}, 1)
49+
// Nothing ever signals ready and no error arrives → must time out, not hang.
50+
err := awaitForward(make(chan struct{}), make(chan error, 1), stop, 10*time.Millisecond)
51+
if err == nil {
52+
t.Fatal("a never-ready forward must time out, not hang")
53+
}
54+
select {
55+
case <-stop:
56+
default:
57+
t.Error("timeout must close stopChan to tear the forwarder down")
58+
}
59+
}
60+
61+
// ── Blocker #3: TLS discovery and credential loading ────────────────────────
62+
63+
// etcdTLSPod returns a Pod whose etcd container points --trusted-ca-file at a
64+
// secret-backed volume mount, mirroring what the operator builds.
65+
func etcdTLSPod() (*corev1.Pod, corev1.Container) {
66+
c := corev1.Container{
67+
Name: "etcd",
68+
Command: []string{"etcd"},
69+
Args: []string{"--trusted-ca-file=/etc/etcd/pki/ca/ca.crt"},
70+
VolumeMounts: []corev1.VolumeMount{{Name: "ca", MountPath: "/etc/etcd/pki/ca"}},
71+
}
72+
pod := &corev1.Pod{
73+
Spec: corev1.PodSpec{
74+
Containers: []corev1.Container{c},
75+
Volumes: []corev1.Volume{{
76+
Name: "ca",
77+
VolumeSource: corev1.VolumeSource{Secret: &corev1.SecretVolumeSource{SecretName: "etcd-ca"}},
78+
}},
79+
},
80+
}
81+
return pod, c
82+
}
83+
84+
func TestFindSecretNameForTLS_Happy(t *testing.T) {
85+
pod, c := etcdTLSPod()
86+
name, err := findSecretNameForTLS(pod, c)
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
if name != "etcd-ca" {
91+
t.Errorf("secret name = %q, want %q", name, "etcd-ca")
92+
}
93+
}
94+
95+
func TestFindSecretNameForTLS_NoCAFlag(t *testing.T) {
96+
c := corev1.Container{Name: "etcd", Args: []string{"--listen-client-urls=http://0.0.0.0:2379"}}
97+
pod := &corev1.Pod{Spec: corev1.PodSpec{Containers: []corev1.Container{c}}}
98+
_, err := findSecretNameForTLS(pod, c)
99+
if !errors.Is(err, errNoTrustedCAFile) {
100+
t.Errorf("expected errNoTrustedCAFile sentinel, got %v", err)
101+
}
102+
}
103+
104+
func TestFindSecretNameForTLS_MountNotFound(t *testing.T) {
105+
c := corev1.Container{Name: "etcd", Args: []string{"--trusted-ca-file=/nowhere/ca.crt"}}
106+
pod := &corev1.Pod{Spec: corev1.PodSpec{Containers: []corev1.Container{c}}}
107+
if _, err := findSecretNameForTLS(pod, c); err == nil {
108+
t.Error("expected an error when no volume backs the CA path")
109+
}
110+
}
111+
112+
func TestLoadCredentials_Happy(t *testing.T) {
113+
cs := fake.NewSimpleClientset(&corev1.Secret{
114+
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "ns1"},
115+
Data: map[string][]byte{
116+
corev1.BasicAuthUsernameKey: []byte("root"),
117+
corev1.BasicAuthPasswordKey: []byte("s3cret"),
118+
},
119+
})
120+
u, p, err := loadCredentials(cs, "ns1", "creds")
121+
if err != nil {
122+
t.Fatal(err)
123+
}
124+
if u != "root" || p != "s3cret" {
125+
t.Errorf("got %q/%q, want root/s3cret", u, p)
126+
}
127+
}
128+
129+
func TestLoadCredentials_DefaultsUsernameToRoot(t *testing.T) {
130+
cs := fake.NewSimpleClientset(&corev1.Secret{
131+
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "ns1"},
132+
Data: map[string][]byte{corev1.BasicAuthPasswordKey: []byte("p")},
133+
})
134+
u, _, err := loadCredentials(cs, "ns1", "creds")
135+
if err != nil {
136+
t.Fatal(err)
137+
}
138+
if u != "root" {
139+
t.Errorf("username default = %q, want root", u)
140+
}
141+
}
142+
143+
func TestLoadCredentials_MissingPassword(t *testing.T) {
144+
cs := fake.NewSimpleClientset(&corev1.Secret{
145+
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "ns1"},
146+
Data: map[string][]byte{corev1.BasicAuthUsernameKey: []byte("root")},
147+
})
148+
if _, _, err := loadCredentials(cs, "ns1", "creds"); err == nil {
149+
t.Error("expected an error when the password key is missing/empty")
150+
}
151+
}
152+
153+
func TestLoadCredentials_NamespacedRef(t *testing.T) {
154+
cs := fake.NewSimpleClientset(&corev1.Secret{
155+
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "other"},
156+
Data: map[string][]byte{corev1.BasicAuthPasswordKey: []byte("p")},
157+
})
158+
// Default namespace is ns1, but the "other/creds" ref must override it.
159+
_, p, err := loadCredentials(cs, "ns1", "other/creds")
160+
if err != nil || p != "p" {
161+
t.Errorf("namespace/name ref not honored: p=%q err=%v", p, err)
162+
}
163+
}
164+
165+
func TestExtractTLSFiles_Happy(t *testing.T) {
166+
cert, key := selfSignedPEM(t)
167+
cs := fake.NewSimpleClientset(&corev1.Secret{
168+
ObjectMeta: metav1.ObjectMeta{Name: "etcd-ca", Namespace: "ns1"},
169+
Data: map[string][]byte{"ca.crt": cert, "tls.crt": cert, "tls.key": key},
170+
})
171+
pool, clientCert, err := extractTLSFiles(cs, "ns1", "etcd-ca")
172+
if err != nil {
173+
t.Fatal(err)
174+
}
175+
if pool == nil || clientCert == nil {
176+
t.Fatal("expected a non-nil CA pool and client certificate")
177+
}
178+
}
179+
180+
func TestExtractTLSFiles_MissingCA(t *testing.T) {
181+
cs := fake.NewSimpleClientset(&corev1.Secret{
182+
ObjectMeta: metav1.ObjectMeta{Name: "etcd-ca", Namespace: "ns1"},
183+
Data: map[string][]byte{"tls.crt": []byte("x"), "tls.key": []byte("y")},
184+
})
185+
if _, _, err := extractTLSFiles(cs, "ns1", "etcd-ca"); err == nil {
186+
t.Error("expected an error when ca.crt is absent from the secret")
187+
}
188+
}
189+
190+
func TestGetTLSConfig_Plaintext(t *testing.T) {
191+
c := corev1.Container{Name: "etcd", Args: []string{"--listen-client-urls=http://0.0.0.0:2379"}}
192+
pod := &corev1.Pod{Spec: corev1.PodSpec{Containers: []corev1.Container{c}}}
193+
cfg, err := getTLSConfig(fake.NewSimpleClientset(), pod, "ns1")
194+
if err != nil {
195+
t.Fatal(err)
196+
}
197+
if cfg != nil {
198+
t.Errorf("a plaintext cluster should yield a nil TLS config, got %+v", cfg)
199+
}
200+
}
201+
202+
func TestGetTLSConfig_TLS(t *testing.T) {
203+
cert, key := selfSignedPEM(t)
204+
pod, _ := etcdTLSPod()
205+
cs := fake.NewSimpleClientset(&corev1.Secret{
206+
ObjectMeta: metav1.ObjectMeta{Name: "etcd-ca", Namespace: "ns1"},
207+
Data: map[string][]byte{"ca.crt": cert, "tls.crt": cert, "tls.key": key},
208+
})
209+
cfg, err := getTLSConfig(cs, pod, "ns1")
210+
if err != nil {
211+
t.Fatal(err)
212+
}
213+
if cfg == nil {
214+
t.Fatal("expected a TLS config for a TLS-enabled cluster")
215+
}
216+
if cfg.MinVersion != tls.VersionTLS12 {
217+
t.Errorf("MinVersion = %d, want TLS 1.2 (%d)", cfg.MinVersion, tls.VersionTLS12)
218+
}
219+
if cfg.RootCAs == nil || len(cfg.Certificates) != 1 {
220+
t.Error("expected a populated RootCAs pool and exactly one client certificate")
221+
}
222+
}
223+
224+
// selfSignedPEM returns a fresh self-signed certificate and its EC private key,
225+
// PEM-encoded — enough to satisfy x509 parsing and tls.X509KeyPair in tests.
226+
func selfSignedPEM(t *testing.T) (certPEM, keyPEM []byte) {
227+
t.Helper()
228+
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
229+
if err != nil {
230+
t.Fatalf("generate key: %v", err)
231+
}
232+
tmpl := &x509.Certificate{
233+
SerialNumber: big.NewInt(1),
234+
Subject: pkix.Name{CommonName: "test-etcd"},
235+
NotBefore: time.Now().Add(-time.Hour),
236+
NotAfter: time.Now().Add(time.Hour),
237+
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
238+
BasicConstraintsValid: true,
239+
IsCA: true,
240+
}
241+
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
242+
if err != nil {
243+
t.Fatalf("create certificate: %v", err)
244+
}
245+
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
246+
keyDER, err := x509.MarshalECPrivateKey(priv)
247+
if err != nil {
248+
t.Fatalf("marshal key: %v", err)
249+
}
250+
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
251+
return certPEM, keyPEM
252+
}

config/default/manager_auth_proxy_patch.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ spec:
3131
capabilities:
3232
drop:
3333
- "ALL"
34-
image: gcr.io/kubebuilder/kube-rbac-proxy:v0.13.1
34+
# gcr.io/kubebuilder/kube-rbac-proxy is gone (the gcr.io/kubebuilder
35+
# registry shut down in early 2025); quay.io/brancz is the upstream
36+
# home of kube-rbac-proxy that kubebuilder pointed users to.
37+
image: quay.io/brancz/kube-rbac-proxy:v0.18.2
3538
args:
3639
- "--secure-listen-address=0.0.0.0:8443"
3740
- "--upstream=http://127.0.0.1:8080/"

controllers/etcdcluster_controller.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,8 +452,19 @@ func (r *EtcdClusterReconciler) bootstrap(
452452
// Pod label on the first reconcile of the seed Pod, so the PDB
453453
// selects it from creation rather than only after MemberList runs
454454
// a few reconciles later.
455+
//
456+
// Written as a merge patch, not an Update: the member controller
457+
// reconciles the seed the moment it is created and its own
458+
// Status().Update races with this write — an optimistic-locked
459+
// Update here loses that race with a conflict, and a cache-backed
460+
// re-Get cannot recover (the informer may not even have the
461+
// object yet). This stamp happens only in the create branch —
462+
// losing it would leave the seed permanently IsVoter=false, and
463+
// both learner memberID discovery and promotion filter their dial
464+
// endpoints to voters, wedging every later scale-up.
465+
stampBase := seed.DeepCopy()
455466
seed.Status.IsVoter = true
456-
if err := r.Status().Update(ctx, seed); err != nil {
467+
if err := r.Status().Patch(ctx, seed, client.MergeFrom(stampBase)); err != nil {
457468
return ctrl.Result{}, err
458469
}
459470
} else if !metav1.IsControlledBy(seed, cluster) {

0 commit comments

Comments
 (0)