Skip to content

Commit 7484b26

Browse files
committed
Update: Migrate E2E Test runner from shell to go based
1 parent 0d3115f commit 7484b26

6 files changed

Lines changed: 242 additions & 165 deletions

File tree

tests/e2e/Dockerfile.test-client

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ FROM golang:1.24-alpine AS builder
33
WORKDIR /src
44
COPY persys-scheduler ./persys-scheduler
55
RUN cd /src/persys-scheduler && go build -o /out/smoke-client ./cmd/smoke-client
6+
COPY tests/e2e ./tests/e2e
7+
RUN cd /src/tests/e2e && go build -o /out/test-runner ./test-runner.go
68

79
FROM alpine:3.21
8-
RUN apk add --no-cache ca-certificates curl jq bash
10+
RUN apk add --no-cache ca-certificates
911
WORKDIR /app
1012
COPY --from=builder /out/smoke-client ./smoke-client
11-
COPY tests/e2e/test-suite.sh ./test-suite.sh
12-
RUN chmod +x ./smoke-client ./test-suite.sh
13+
COPY --from=builder /out/test-runner ./test-runner
14+
RUN chmod +x ./smoke-client ./test-runner
1315

14-
CMD ["/bin/sh", "-c", "./test-suite.sh"]
16+
CMD ["./test-runner"]

tests/e2e/Makefile

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
.PHONY: help test test-docker test-script test-go clean
1+
.PHONY: help test test-docker test-go clean
22

33
help:
44
@echo "Persys Scheduler E2E"
55
@echo "===================="
66
@echo "test - Run docker-compose based scheduler + compute-agent lifecycle suite"
77
@echo "test-docker - Same as test"
8-
@echo "test-script - Run shell lifecycle suite against locally-running scheduler/agent"
9-
@echo "test-go - Run Go health/metrics probe against locally-running scheduler"
8+
@echo "test-go - Run Go lifecycle suite against locally-running scheduler/agent"
109
@echo "clean - Tear down compose test stack"
1110

1211
test: test-docker
@@ -15,10 +14,6 @@ test-docker:
1514
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test-client
1615
docker compose -f docker-compose.test.yml down
1716

18-
test-script:
19-
chmod +x test-suite.sh
20-
./test-suite.sh
21-
2217
test-go:
2318
go run test-runner.go
2419

tests/e2e/README.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ The tests cover:
88

99
- Scheduler startup (`/health`, `/metrics`)
1010
- Compute-agent startup (`/health`)
11-
- Scheduler gRPC control API lifecycle through `cmd/smoke-client`
12-
- `RegisterNode`
13-
- `Heartbeat`
11+
- Scheduler gRPC control API lifecycle through Go runner + `cmd/smoke-client`
12+
- retries apply until scheduler can place workload
1413
- `ApplyWorkload` (container)
1514
- `GetWorkload`
1615
- `ListWorkloads`
@@ -32,26 +31,23 @@ This starts:
3231
- `etcd`
3332
- `compute-agent` (real runtime node service from `compute-agent/`)
3433
- `persys-scheduler` (with `-insecure`)
35-
- `test-client` (runs `test-suite.sh`)
34+
- `test-client` (runs `test-runner`)
3635

37-
### Script-only (against existing local scheduler)
36+
### Go runner only (against existing local scheduler)
3837

3938
```bash
4039
cd tests/e2e
4140
SCHEDULER_METRICS_URL=http://localhost:8084 \
4241
SCHEDULER_GRPC_ADDR=localhost:8085 \
4342
AGENT_METRICS_URL=http://localhost:8080 \
44-
TEST_NODE_ENDPOINT=localhost:50051 \
45-
./test-suite.sh
43+
go run test-runner.go
4644
```
4745

4846
## Environment variables
4947

5048
- `SCHEDULER_METRICS_URL` (default `http://localhost:8084`)
5149
- `SCHEDULER_GRPC_ADDR` (default `localhost:8085`)
5250
- `AGENT_METRICS_URL` (default `http://compute-agent:8080`)
53-
- `TEST_NODE_ID` (default `e2e-node-1`)
54-
- `TEST_NODE_ENDPOINT` (default `compute-agent:50051`)
5551
- `TEST_WORKLOAD_ID` (default `e2e-workload-1`)
5652
- `RETRY_INTERVAL` (default `2`)
5753
- `MAX_RETRIES` (default `40`)

tests/e2e/docker-compose.test.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,13 @@ services:
5454
- SCHEDULER_METRICS_URL=http://persys-scheduler:8084
5555
- SCHEDULER_GRPC_ADDR=persys-scheduler:8085
5656
- AGENT_METRICS_URL=http://compute-agent:8080
57-
- TEST_NODE_ID=e2e-node-1
58-
- TEST_NODE_ENDPOINT=compute-agent:50051
5957
- TEST_WORKLOAD_ID=e2e-workload-1
6058
depends_on:
6159
- persys-scheduler
6260
- compute-agent
6361
networks:
6462
- persys-cloud-test
65-
command: ["/bin/sh", "-c", "./test-suite.sh"]
63+
command: ["./test-runner"]
6664

6765
networks:
6866
persys-cloud-test:

tests/e2e/test-runner.go

Lines changed: 228 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,179 @@
11
package main
22

33
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
47
"fmt"
58
"io"
9+
"log"
610
"net/http"
711
"os"
12+
"os/exec"
13+
"strconv"
814
"strings"
915
"time"
1016
)
1117

1218
func main() {
13-
metricsURL := getenvDefault("SCHEDULER_METRICS_URL", "http://localhost:8084")
19+
cfg := loadConfig()
1420
client := &http.Client{Timeout: 10 * time.Second}
1521

16-
if err := waitFor(metricsURL+"/health", client, 30, 2*time.Second); err != nil {
17-
panic(fmt.Sprintf("health check failed: %v", err))
18-
}
19-
if err := waitFor(metricsURL+"/metrics", client, 30, 2*time.Second); err != nil {
20-
panic(fmt.Sprintf("metrics endpoint failed: %v", err))
22+
must(waitForHTTP(cfg.schedulerMetricsURL+"/health", client, cfg.maxRetries, cfg.retryInterval))
23+
must(waitForHTTP(cfg.schedulerMetricsURL+"/metrics", client, cfg.maxRetries, cfg.retryInterval))
24+
must(waitForHTTP(cfg.agentMetricsURL+"/health", client, cfg.maxRetries, cfg.retryInterval))
25+
26+
logStep("apply workload")
27+
must(applyWorkloadWithRetry(cfg))
28+
29+
var workload getWorkloadResponse
30+
must(poll(cfg.maxRetries, cfg.retryInterval, func() (bool, error) {
31+
out, err := runSmokeCapture(cfg.schedulerGRPCAddr, "-op", "get-workload", "-workload-id", cfg.testWorkloadID)
32+
if err != nil {
33+
return false, nil
34+
}
35+
if err := unmarshalSmokeJSON(out, &workload); err != nil {
36+
return false, nil
37+
}
38+
s := strings.ToLower(strings.TrimSpace(workload.Workload.Status))
39+
return s == "running" || s == "pending" || s == "unknown", nil
40+
}))
41+
42+
listOut, err := runSmokeCapture(cfg.schedulerGRPCAddr, "-op", "list-workloads")
43+
must(err)
44+
var listResp listWorkloadsResponse
45+
must(unmarshalSmokeJSON(listOut, &listResp))
46+
if !containsWorkload(listResp.Workloads, cfg.testWorkloadID) {
47+
failf("workload %s missing from list-workloads", cfg.testWorkloadID)
2148
}
2249

23-
body, err := getBody(metricsURL+"/metrics", client)
50+
summaryOut, err := runSmokeCapture(cfg.schedulerGRPCAddr, "-op", "cluster-summary")
2451
if err != nil {
25-
panic(fmt.Sprintf("metrics fetch failed: %v", err))
52+
failf("cluster-summary failed: %v", err)
53+
}
54+
var summary clusterSummaryResponse
55+
must(unmarshalSmokeJSON(summaryOut, &summary))
56+
if summary.TotalWorkloads < 1 {
57+
failf("cluster summary reported total_workloads=%d, want >=1", summary.TotalWorkloads)
2658
}
2759

60+
logStep("delete workload")
61+
must(runSmoke(cfg.schedulerGRPCAddr, "-op", "delete-workload", "-workload-id", cfg.testWorkloadID))
62+
must(poll(cfg.maxRetries, cfg.retryInterval, func() (bool, error) {
63+
out, err := runSmokeCapture(cfg.schedulerGRPCAddr, "-op", "get-workload", "-workload-id", cfg.testWorkloadID)
64+
if err != nil {
65+
return true, nil // NotFound is acceptable terminal state.
66+
}
67+
var r getWorkloadResponse
68+
if err := unmarshalSmokeJSON(out, &r); err != nil {
69+
return false, nil
70+
}
71+
s := strings.ToLower(strings.TrimSpace(r.Workload.Status))
72+
return s == "deleting" || s == "deleted", nil
73+
}))
74+
75+
body, err := getBody(cfg.schedulerMetricsURL+"/metrics", client)
76+
must(err)
2877
required := []string{
29-
"go_gc_duration_seconds",
30-
"process_cpu_seconds_total",
78+
"persys_scheduler_grpc_server_requests_total",
79+
"persys_scheduler_grpc_server_request_duration_seconds",
80+
"persys_scheduler_agent_rpc_requests_total",
81+
"persys_scheduler_agent_rpc_duration_seconds",
82+
"persys_scheduler_nodes_status",
83+
"persys_scheduler_workloads_status",
84+
"persys_scheduler_reconciliation_results_total",
3185
}
3286
for _, metric := range required {
3387
if !strings.Contains(body, metric) {
34-
panic(fmt.Sprintf("expected metric %q not found", metric))
88+
failf("expected metric %q not found", metric)
3589
}
3690
}
3791

38-
fmt.Println("✅ Go-based E2E probe passed")
92+
fmt.Println("E2E scheduler + compute-agent suite passed")
93+
}
94+
95+
type config struct {
96+
schedulerMetricsURL string
97+
schedulerGRPCAddr string
98+
agentMetricsURL string
99+
testWorkloadID string
100+
retryInterval time.Duration
101+
maxRetries int
102+
}
103+
104+
type getWorkloadResponse struct {
105+
Workload workloadView `json:"workload"`
106+
}
107+
108+
type listWorkloadsResponse struct {
109+
Workloads []workloadView `json:"workloads"`
110+
}
111+
112+
type workloadView struct {
113+
WorkloadID string `json:"workloadId"`
114+
Status string `json:"status"`
115+
}
116+
117+
type clusterSummaryResponse struct {
118+
TotalWorkloads int32 `json:"totalWorkloads"`
119+
}
120+
121+
func loadConfig() config {
122+
return config{
123+
schedulerMetricsURL: getenvDefault("SCHEDULER_METRICS_URL", "http://localhost:8084"),
124+
schedulerGRPCAddr: getenvDefault("SCHEDULER_GRPC_ADDR", "localhost:8085"),
125+
agentMetricsURL: getenvDefault("AGENT_METRICS_URL", "http://compute-agent:8080"),
126+
testWorkloadID: getenvDefault("TEST_WORKLOAD_ID", "e2e-workload-1"),
127+
retryInterval: getenvDurationDefault("RETRY_INTERVAL", 2*time.Second),
128+
maxRetries: getenvIntDefault("MAX_RETRIES", 40),
129+
}
130+
}
131+
132+
func applyWorkloadWithRetry(cfg config) error {
133+
return poll(cfg.maxRetries, cfg.retryInterval, func() (bool, error) {
134+
err := runSmoke(cfg.schedulerGRPCAddr,
135+
"-op", "apply-container",
136+
"-workload-id", cfg.testWorkloadID,
137+
"-container-image", "busybox:latest",
138+
"-container-cmd", "sh,-c,sleep 60",
139+
"-w-cpu", "100",
140+
"-w-mem", "128",
141+
"-w-disk", "1",
142+
)
143+
if err != nil {
144+
msg := strings.ToLower(err.Error())
145+
if strings.Contains(msg, "no suitable node") || strings.Contains(msg, "cannot place workload") {
146+
return false, nil
147+
}
148+
return false, err
149+
}
150+
return true, nil
151+
})
152+
}
153+
154+
func runSmoke(schedulerAddr string, args ...string) error {
155+
_, err := runSmokeCapture(schedulerAddr, args...)
156+
return err
39157
}
40158

41-
func waitFor(url string, client *http.Client, retries int, interval time.Duration) error {
159+
func runSmokeCapture(schedulerAddr string, args ...string) (string, error) {
160+
cmdArgs := append([]string{"-scheduler", schedulerAddr}, args...)
161+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
162+
defer cancel()
163+
164+
cmd := exec.CommandContext(ctx, "./smoke-client", cmdArgs...)
165+
var stdout, stderr bytes.Buffer
166+
cmd.Stdout = &stdout
167+
cmd.Stderr = &stderr
168+
err := cmd.Run()
169+
out := strings.TrimSpace(stdout.String() + "\n" + stderr.String())
170+
if err != nil {
171+
return out, fmt.Errorf("smoke-client %v failed: %w\n%s", args, err, out)
172+
}
173+
return out, nil
174+
}
175+
176+
func waitForHTTP(url string, client *http.Client, retries int, interval time.Duration) error {
42177
for i := 0; i < retries; i++ {
43178
resp, err := client.Get(url)
44179
if err == nil && resp.StatusCode == http.StatusOK {
@@ -69,9 +204,89 @@ func getBody(url string, client *http.Client) (string, error) {
69204
return string(b), nil
70205
}
71206

207+
func poll(retries int, interval time.Duration, f func() (bool, error)) error {
208+
for i := 0; i < retries; i++ {
209+
ok, err := f()
210+
if err != nil {
211+
return err
212+
}
213+
if ok {
214+
return nil
215+
}
216+
time.Sleep(interval)
217+
}
218+
return fmt.Errorf("timeout while polling condition")
219+
}
220+
221+
func unmarshalSmokeJSON(out string, target interface{}) error {
222+
raw, err := extractJSONObject(out)
223+
if err != nil {
224+
return err
225+
}
226+
return json.Unmarshal([]byte(raw), target)
227+
}
228+
229+
func extractJSONObject(s string) (string, error) {
230+
start := strings.Index(s, "{")
231+
end := strings.LastIndex(s, "}")
232+
if start == -1 || end == -1 || end <= start {
233+
return "", fmt.Errorf("no JSON object found in output: %s", s)
234+
}
235+
return s[start : end+1], nil
236+
}
237+
238+
func containsWorkload(workloads []workloadView, workloadID string) bool {
239+
for _, w := range workloads {
240+
if strings.TrimSpace(w.WorkloadID) == workloadID {
241+
return true
242+
}
243+
}
244+
return false
245+
}
246+
247+
func getenvIntDefault(key string, fallback int) int {
248+
v := strings.TrimSpace(os.Getenv(key))
249+
if v == "" {
250+
return fallback
251+
}
252+
n, err := strconv.Atoi(v)
253+
if err != nil {
254+
return fallback
255+
}
256+
return n
257+
}
258+
259+
func getenvDurationDefault(key string, fallback time.Duration) time.Duration {
260+
v := strings.TrimSpace(os.Getenv(key))
261+
if v == "" {
262+
return fallback
263+
}
264+
if d, err := time.ParseDuration(v); err == nil {
265+
return d
266+
}
267+
if s, err := strconv.Atoi(v); err == nil {
268+
return time.Duration(s) * time.Second
269+
}
270+
return fallback
271+
}
272+
72273
func getenvDefault(key, fallback string) string {
73274
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
74275
return v
75276
}
76277
return fallback
77278
}
279+
280+
func logStep(msg string) {
281+
log.Printf("==> %s", msg)
282+
}
283+
284+
func must(err error) {
285+
if err != nil {
286+
failf("%v", err)
287+
}
288+
}
289+
290+
func failf(format string, args ...interface{}) {
291+
log.Fatalf(format, args...)
292+
}

0 commit comments

Comments
 (0)