Skip to content

Commit 2d34685

Browse files
authored
Merge pull request #4131 from mogsie/test-e2e-impro
✨ (go/v4) Add Metrics Validation and Helper Functions to E2E Tests
2 parents 33a2f3d + d279035 commit 2d34685

File tree

24 files changed

+1817
-227
lines changed

24 files changed

+1817
-227
lines changed

docs/book/src/cronjob-tutorial/testdata/project/test/e2e/e2e_suite_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ func TestE2E(t *testing.T) {
5757
}
5858

5959
var _ = BeforeSuite(func() {
60+
By("Ensure that Prometheus is enabled")
61+
_ = utils.UncommentCode("config/default/kustomization.yaml", "#- ../prometheus", "#")
62+
6063
By("generating files")
6164
cmd := exec.Command("make", "generate")
6265
_, err := utils.Run(cmd)

docs/book/src/cronjob-tutorial/testdata/project/test/e2e/e2e_test.go

Lines changed: 173 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ limitations under the License.
1717
package e2e
1818

1919
import (
20+
"encoding/json"
2021
"fmt"
22+
"os"
2123
"os/exec"
24+
"path/filepath"
2225
"time"
2326

2427
. "github.com/onsi/ginkgo/v2"
@@ -27,34 +30,44 @@ import (
2730
"tutorial.kubebuilder.io/project/test/utils"
2831
)
2932

33+
// namespace where the project is deployed in
3034
const namespace = "project-system"
3135

32-
// Define a set of end-to-end (e2e) tests to validate the behavior of the controller.
33-
var _ = Describe("controller", Ordered, func() {
36+
// serviceAccountName created for the project
37+
const serviceAccountName = "project-controller-manager"
38+
39+
// metricsServiceName is the name of the metrics service of the project
40+
const metricsServiceName = "project-controller-manager-metrics-service"
41+
42+
// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data
43+
const metricsRoleBindingName = "project-metrics-binding"
44+
45+
var _ = Describe("Manager", Ordered, func() {
3446
// Before running the tests, set up the environment by creating the namespace,
3547
// installing CRDs, and deploying the controller.
3648
BeforeAll(func() {
3749
By("creating manager namespace")
3850
cmd := exec.Command("kubectl", "create", "ns", namespace)
39-
_, err := utils.Run(cmd)
40-
ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create namespace")
51+
Expect(utils.Run(cmd)).Error().NotTo(HaveOccurred(), "Failed to create namespace")
4152

4253
By("installing CRDs")
4354
cmd = exec.Command("make", "install")
44-
_, err = utils.Run(cmd)
45-
ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to install CRDs")
55+
Expect(utils.Run(cmd)).Error().NotTo(HaveOccurred(), "Failed to install CRDs")
4656

4757
By("deploying the controller-manager")
4858
cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectImage))
49-
_, err = utils.Run(cmd)
50-
ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager")
59+
Expect(utils.Run(cmd)).Error().NotTo(HaveOccurred(), "Failed to deploy the controller-manager")
5160
})
5261

5362
// After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs,
5463
// and deleting the namespace.
5564
AfterAll(func() {
65+
By("cleaning up the curl pod for metrics")
66+
cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace)
67+
_, _ = utils.Run(cmd)
68+
5669
By("undeploying the controller-manager")
57-
cmd := exec.Command("make", "undeploy")
70+
cmd = exec.Command("make", "undeploy")
5871
_, _ = utils.Run(cmd)
5972

6073
By("uninstalling CRDs")
@@ -66,13 +79,15 @@ var _ = Describe("controller", Ordered, func() {
6679
_, _ = utils.Run(cmd)
6780
})
6881

69-
// The Context block contains the actual tests that validate the operator's behavior.
70-
Context("Operator", func() {
71-
It("should run successfully", func() {
72-
var controllerPodName string
82+
SetDefaultEventuallyTimeout(2 * time.Minute)
83+
SetDefaultEventuallyPollingInterval(time.Second)
7384

85+
// The Context block contains the actual tests that validate the manager's behavior.
86+
Context("Manager", func() {
87+
var controllerPodName string
88+
It("should run successfully", func() {
7489
By("validating that the controller-manager pod is running as expected")
75-
verifyControllerUp := func() error {
90+
verifyControllerUp := func(g Gomega) {
7691
// Get the name of the controller-manager pod
7792
cmd := exec.Command("kubectl", "get",
7893
"pods", "-l", "control-plane=controller-manager",
@@ -84,31 +99,161 @@ var _ = Describe("controller", Ordered, func() {
8499
)
85100

86101
podOutput, err := utils.Run(cmd)
87-
ExpectWithOffset(2, err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information")
102+
g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information")
88103
podNames := utils.GetNonEmptyLines(string(podOutput))
89-
if len(podNames) != 1 {
90-
return fmt.Errorf("expected 1 controller pod running, but got %d", len(podNames))
91-
}
104+
g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running")
92105
controllerPodName = podNames[0]
93-
ExpectWithOffset(2, controllerPodName).Should(ContainSubstring("controller-manager"))
106+
g.Expect(controllerPodName).To(ContainSubstring("controller-manager"))
94107

95108
// Validate the pod's status
96109
cmd = exec.Command("kubectl", "get",
97110
"pods", controllerPodName, "-o", "jsonpath={.status.phase}",
98111
"-n", namespace,
99112
)
100-
status, err := utils.Run(cmd)
101-
ExpectWithOffset(2, err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod status")
102-
if string(status) != "Running" {
103-
return fmt.Errorf("controller pod in %s status", status)
104-
}
105-
return nil
113+
g.Expect(utils.Run(cmd)).To(BeEquivalentTo("Running"), "Incorrect controller-manager pod status")
106114
}
107115
// Repeatedly check if the controller-manager pod is running until it succeeds or times out.
108-
EventuallyWithOffset(1, verifyControllerUp, time.Minute, time.Second).Should(Succeed())
116+
Eventually(verifyControllerUp).Should(Succeed())
117+
})
118+
119+
It("should ensure the metrics endpoint is serving metrics", func() {
120+
By("creating a ClusterRoleBinding for the service account to allow access to metrics")
121+
cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName,
122+
"--clusterrole=project-metrics-reader",
123+
fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName),
124+
)
125+
_, err := utils.Run(cmd)
126+
Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding")
127+
128+
By("validating that the metrics service is available")
129+
cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace)
130+
_, err = utils.Run(cmd)
131+
Expect(err).NotTo(HaveOccurred(), "Metrics service should exist")
132+
133+
By("validating that the ServiceMonitor for Prometheus is applied in the namespace")
134+
cmd = exec.Command("kubectl", "get", "ServiceMonitor", "-n", namespace)
135+
_, err = utils.Run(cmd)
136+
Expect(err).NotTo(HaveOccurred(), "ServiceMonitor should exist")
137+
138+
By("getting the service account token")
139+
token, err := serviceAccountToken()
140+
Expect(err).NotTo(HaveOccurred())
141+
Expect(token).NotTo(BeEmpty())
142+
143+
By("waiting for the metrics endpoint to be ready")
144+
verifyMetricsEndpointReady := func(g Gomega) {
145+
cmd := exec.Command("kubectl", "get", "endpoints", metricsServiceName, "-n", namespace)
146+
output, err := utils.Run(cmd)
147+
g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve endpoints information")
148+
g.Expect(string(output)).To(ContainSubstring("8443"), "Metrics endpoint is not ready")
149+
}
150+
Eventually(verifyMetricsEndpointReady).Should(Succeed())
151+
152+
By("verifying that the controller manager is serving the metrics server")
153+
verifyMetricsServerStarted := func(g Gomega) {
154+
cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace)
155+
logs, err := utils.Run(cmd)
156+
g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller manager logs")
157+
g.Expect(string(logs)).To(ContainSubstring("controller-runtime.metrics\tServing metrics server"),
158+
"Metrics server not yet started")
159+
}
160+
Eventually(verifyMetricsServerStarted).Should(Succeed())
161+
162+
By("creating the curl-metrics pod to access the metrics endpoint")
163+
cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never",
164+
"--namespace", namespace,
165+
"--image=curlimages/curl:7.78.0",
166+
"--", "/bin/sh", "-c", fmt.Sprintf(
167+
"curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics",
168+
token, metricsServiceName, namespace))
169+
Expect(utils.Run(cmd)).Error().NotTo(HaveOccurred(), "Failed to create curl-metrics pod")
170+
171+
By("waiting for the curl-metrics pod to complete.")
172+
verifyCurlUp := func(g Gomega) {
173+
cmd := exec.Command("kubectl", "get", "pods", "curl-metrics",
174+
"-o", "jsonpath={.status.phase}",
175+
"-n", namespace)
176+
g.Expect(utils.Run(cmd)).To(BeEquivalentTo("Succeeded"), "curl pod in wrong status")
177+
}
178+
Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed())
179+
180+
By("getting the metrics by checking curl-metrics logs")
181+
metricsOutput := getMetricsOutput()
182+
Expect(metricsOutput).To(ContainSubstring(
183+
"controller_runtime_reconcile_total",
184+
))
109185
})
110186

111-
// TODO(user): Customize the e2e test suite to include
112-
// additional scenarios specific to your project.
187+
// TODO: Customize the e2e test suite with scenarios specific to your project.
188+
// Consider applying sample/CR(s) and check their status and/or verifying
189+
// the reconciliation by using the metrics, i.e.:
190+
// metricsOutput := getMetricsOutput()
191+
// Expect(metricsOutput).To(ContainSubstring(
192+
// fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`,
193+
// strings.ToLower(<Kind>),
194+
// ))
113195
})
114196
})
197+
198+
// serviceAccountToken returns a token for the specified service account in the given namespace.
199+
// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request
200+
// and parsing the resulting token from the API response.
201+
func serviceAccountToken() (string, error) {
202+
const tokenRequestRawString = `{
203+
"apiVersion": "authentication.k8s.io/v1",
204+
"kind": "TokenRequest"
205+
}`
206+
207+
// Temporary file to store the token request
208+
secretName := fmt.Sprintf("%s-token-request", serviceAccountName)
209+
tokenRequestFile := filepath.Join("/tmp", secretName)
210+
err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644))
211+
if err != nil {
212+
return "", err
213+
}
214+
215+
var out string
216+
var rawJson string
217+
verifyTokenCreation := func(g Gomega) {
218+
// Execute kubectl command to create the token
219+
cmd := exec.Command("kubectl", "create", "--raw", fmt.Sprintf(
220+
"/api/v1/namespaces/%s/serviceaccounts/%s/token",
221+
namespace,
222+
serviceAccountName,
223+
), "-f", tokenRequestFile)
224+
225+
output, err := cmd.CombinedOutput()
226+
g.Expect(err).NotTo(HaveOccurred())
227+
228+
rawJson = string(output)
229+
230+
// Parse the JSON output to extract the token
231+
var token tokenRequest
232+
err = json.Unmarshal([]byte(rawJson), &token)
233+
g.Expect(err).NotTo(HaveOccurred())
234+
235+
out = token.Status.Token
236+
}
237+
Eventually(verifyTokenCreation).Should(Succeed())
238+
239+
return out, err
240+
}
241+
242+
// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint.
243+
func getMetricsOutput() string {
244+
By("getting the curl-metrics logs")
245+
cmd := exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace)
246+
metricsOutput, err := utils.Run(cmd)
247+
Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod")
248+
metricsOutputStr := string(metricsOutput)
249+
Expect(metricsOutputStr).To(ContainSubstring("< HTTP/1.1 200 OK"))
250+
return metricsOutputStr
251+
}
252+
253+
// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response,
254+
// containing only the token field that we need to extract.
255+
type tokenRequest struct {
256+
Status struct {
257+
Token string `json:"token"`
258+
} `json:"status"`
259+
}

docs/book/src/cronjob-tutorial/testdata/project/test/utils/utils.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ limitations under the License.
1717
package utils
1818

1919
import (
20+
"bufio"
21+
"bytes"
2022
"fmt"
2123
"os"
2224
"os/exec"
@@ -198,3 +200,52 @@ func GetProjectDir() (string, error) {
198200
wd = strings.Replace(wd, "/test/e2e", "", -1)
199201
return wd, nil
200202
}
203+
204+
// UncommentCode searches for target in the file and remove the comment prefix
205+
// of the target content. The target content may span multiple lines.
206+
func UncommentCode(filename, target, prefix string) error {
207+
// false positive
208+
// nolint:gosec
209+
content, err := os.ReadFile(filename)
210+
if err != nil {
211+
return err
212+
}
213+
strContent := string(content)
214+
215+
idx := strings.Index(strContent, target)
216+
if idx < 0 {
217+
return fmt.Errorf("unable to find the code %s to be uncomment", target)
218+
}
219+
220+
out := new(bytes.Buffer)
221+
_, err = out.Write(content[:idx])
222+
if err != nil {
223+
return err
224+
}
225+
226+
scanner := bufio.NewScanner(bytes.NewBufferString(target))
227+
if !scanner.Scan() {
228+
return nil
229+
}
230+
for {
231+
_, err := out.WriteString(strings.TrimPrefix(scanner.Text(), prefix))
232+
if err != nil {
233+
return err
234+
}
235+
// Avoid writing a newline in case the previous line was the last in target.
236+
if !scanner.Scan() {
237+
break
238+
}
239+
if _, err := out.WriteString("\n"); err != nil {
240+
return err
241+
}
242+
}
243+
244+
_, err = out.Write(content[idx+len(target):])
245+
if err != nil {
246+
return err
247+
}
248+
// false positive
249+
// nolint:gosec
250+
return os.WriteFile(filename, out.Bytes(), 0644)
251+
}

docs/book/src/getting-started/testdata/project/test/e2e/e2e_suite_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ func TestE2E(t *testing.T) {
5757
}
5858

5959
var _ = BeforeSuite(func() {
60+
By("Ensure that Prometheus is enabled")
61+
_ = utils.UncommentCode("config/default/kustomization.yaml", "#- ../prometheus", "#")
62+
6063
By("generating files")
6164
cmd := exec.Command("make", "generate")
6265
_, err := utils.Run(cmd)

0 commit comments

Comments
 (0)