Skip to content

Commit ac54a21

Browse files
feat: enable parallel e2e test execution with dynamic port allocation
Introduce TestContainer to manage Docker containers with dynamically allocated ports, eliminating port conflicts and enabling parallel test execution. This significantly speeds up CI runs on high-resource machines. Changes: - Add container.go with TestContainer struct for dynamic port management - Migrate all e2e tests to use TestContainer instead of global setup - Add t.Parallel() to test functions for concurrent execution - Remove -p 1 from Makefile now that port conflicts are resolved - Replace slog logging with t.Log/t.Logf for cleaner test output
1 parent ba0852c commit ac54a21

9 files changed

+608
-454
lines changed

server/Makefile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@ dev: build $(RECORDING_DIR)
3535

3636
test:
3737
go vet ./...
38-
# Run tests sequentially (-p 1) to avoid port conflicts in e2e tests
39-
# (all e2e tests bind to the same ports: 10001, 9222)
40-
go test -v -race -p 1 ./...
38+
# E2E tests use dynamic ports via TestContainer, enabling parallel execution
39+
go test -v -race ./...
4140

4241
clean:
4342
@rm -rf $(BIN_DIR)

server/e2e/container.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package e2e
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net"
7+
"net/http"
8+
"os/exec"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
logctx "github.com/onkernel/kernel-images/server/lib/logger"
14+
instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi"
15+
)
16+
17+
// TestContainer manages a Docker container with dynamically allocated ports.
18+
// This enables parallel test execution by giving each test its own ports.
19+
type TestContainer struct {
20+
Name string
21+
Image string
22+
APIPort int // dynamically allocated host port -> container 10001
23+
CDPPort int // dynamically allocated host port -> container 9222
24+
cmd *exec.Cmd
25+
exitCh <-chan error
26+
}
27+
28+
// ContainerConfig holds optional configuration for container startup.
29+
type ContainerConfig struct {
30+
Env map[string]string
31+
HostAccess bool // Add host.docker.internal mapping
32+
}
33+
34+
// NewTestContainer creates a new test container with dynamically allocated ports.
35+
// Works with both *testing.T and *testing.B (any testing.TB).
36+
func NewTestContainer(tb testing.TB, image string) *TestContainer {
37+
tb.Helper()
38+
39+
apiPort, err := findFreePort()
40+
if err != nil {
41+
tb.Fatalf("failed to find free API port: %v", err)
42+
}
43+
44+
cdpPort, err := findFreePort()
45+
if err != nil {
46+
tb.Fatalf("failed to find free CDP port: %v", err)
47+
}
48+
49+
// Generate unique container name based on test name
50+
name := fmt.Sprintf("e2e-%s-%d", sanitizeTestName(tb.Name()), apiPort)
51+
52+
return &TestContainer{
53+
Name: name,
54+
Image: image,
55+
APIPort: apiPort,
56+
CDPPort: cdpPort,
57+
}
58+
}
59+
60+
// findFreePort finds an available TCP port by binding to port 0.
61+
func findFreePort() (int, error) {
62+
l, err := net.Listen("tcp", "127.0.0.1:0")
63+
if err != nil {
64+
return 0, err
65+
}
66+
defer l.Close()
67+
return l.Addr().(*net.TCPAddr).Port, nil
68+
}
69+
70+
// sanitizeTestName converts a test name to a valid container name suffix.
71+
func sanitizeTestName(name string) string {
72+
// Replace slashes and other invalid characters
73+
name = strings.ReplaceAll(name, "/", "-")
74+
name = strings.ReplaceAll(name, " ", "-")
75+
name = strings.ToLower(name)
76+
// Truncate to reasonable length
77+
if len(name) > 40 {
78+
name = name[:40]
79+
}
80+
return name
81+
}
82+
83+
// Start starts the container with the given configuration.
84+
func (c *TestContainer) Start(ctx context.Context, cfg ContainerConfig) error {
85+
logger := logctx.FromContext(ctx)
86+
87+
// Clean up any existing container with this name
88+
_ = c.cleanup(ctx)
89+
90+
args := []string{
91+
"run",
92+
"--name", c.Name,
93+
"--privileged",
94+
"-p", fmt.Sprintf("%d:10001", c.APIPort),
95+
"-p", fmt.Sprintf("%d:9222", c.CDPPort),
96+
"--tmpfs", "/dev/shm:size=2g,mode=1777",
97+
}
98+
99+
if cfg.HostAccess {
100+
args = append(args, "--add-host=host.docker.internal:host-gateway")
101+
}
102+
103+
// Add environment variables
104+
// Ensure CHROMIUM_FLAGS includes --no-sandbox for CI
105+
envCopy := make(map[string]string)
106+
for k, v := range cfg.Env {
107+
envCopy[k] = v
108+
}
109+
if _, ok := envCopy["CHROMIUM_FLAGS"]; !ok {
110+
envCopy["CHROMIUM_FLAGS"] = "--no-sandbox"
111+
} else if !strings.Contains(envCopy["CHROMIUM_FLAGS"], "--no-sandbox") {
112+
envCopy["CHROMIUM_FLAGS"] = envCopy["CHROMIUM_FLAGS"] + " --no-sandbox"
113+
}
114+
115+
for k, v := range envCopy {
116+
args = append(args, "-e", fmt.Sprintf("%s=%s", k, v))
117+
}
118+
args = append(args, c.Image)
119+
120+
logger.Info("[docker]", "action", "run", "container", c.Name, "apiPort", c.APIPort, "cdpPort", c.CDPPort)
121+
122+
c.cmd = exec.CommandContext(ctx, "docker", args...)
123+
if err := c.cmd.Start(); err != nil {
124+
return fmt.Errorf("failed to start container: %w", err)
125+
}
126+
127+
// Create exit channel to detect container crashes
128+
exitCh := make(chan error, 1)
129+
go func() {
130+
exitCh <- c.cmd.Wait()
131+
}()
132+
c.exitCh = exitCh
133+
134+
return nil
135+
}
136+
137+
// Stop stops and removes the container.
138+
func (c *TestContainer) Stop(ctx context.Context) error {
139+
return c.cleanup(ctx)
140+
}
141+
142+
// cleanup removes the container if it exists.
143+
func (c *TestContainer) cleanup(ctx context.Context) error {
144+
// Kill the container
145+
killCmd := exec.CommandContext(ctx, "docker", "kill", c.Name)
146+
_ = killCmd.Run() // Ignore errors - container may not exist
147+
148+
// Remove the container
149+
rmCmd := exec.CommandContext(ctx, "docker", "rm", "-f", c.Name)
150+
return rmCmd.Run()
151+
}
152+
153+
// APIBaseURL returns the URL for the container's API server.
154+
func (c *TestContainer) APIBaseURL() string {
155+
return fmt.Sprintf("http://127.0.0.1:%d", c.APIPort)
156+
}
157+
158+
// CDPURL returns the WebSocket URL for the container's DevTools proxy.
159+
func (c *TestContainer) CDPURL() string {
160+
return fmt.Sprintf("ws://127.0.0.1:%d/", c.CDPPort)
161+
}
162+
163+
// APIClient creates an OpenAPI client for this container's API.
164+
func (c *TestContainer) APIClient() (*instanceoapi.ClientWithResponses, error) {
165+
return instanceoapi.NewClientWithResponses(c.APIBaseURL())
166+
}
167+
168+
// WaitReady waits for the container's API to become ready.
169+
func (c *TestContainer) WaitReady(ctx context.Context) error {
170+
return c.waitHTTPOrExit(ctx, c.APIBaseURL()+"/spec.yaml")
171+
}
172+
173+
// waitHTTPOrExit waits for an HTTP endpoint to return 200 OK, or for the container to exit.
174+
func (c *TestContainer) waitHTTPOrExit(ctx context.Context, url string) error {
175+
ticker := time.NewTicker(200 * time.Millisecond)
176+
defer ticker.Stop()
177+
178+
client := &http.Client{Timeout: 2 * time.Second}
179+
180+
for {
181+
select {
182+
case <-ctx.Done():
183+
return ctx.Err()
184+
case err := <-c.exitCh:
185+
return fmt.Errorf("container exited while waiting for API: %w", err)
186+
case <-ticker.C:
187+
resp, err := client.Get(url)
188+
if err == nil {
189+
resp.Body.Close()
190+
if resp.StatusCode == http.StatusOK {
191+
return nil
192+
}
193+
}
194+
}
195+
}
196+
}
197+
198+
// ExitCh returns a channel that receives an error when the container exits.
199+
func (c *TestContainer) ExitCh() <-chan error {
200+
return c.exitCh
201+
}
202+
203+
// WaitDevTools waits for the CDP WebSocket endpoint to be ready.
204+
func (c *TestContainer) WaitDevTools(ctx context.Context) error {
205+
addr := fmt.Sprintf("127.0.0.1:%d", c.CDPPort)
206+
ticker := time.NewTicker(200 * time.Millisecond)
207+
defer ticker.Stop()
208+
209+
for {
210+
select {
211+
case <-ctx.Done():
212+
return ctx.Err()
213+
case <-ticker.C:
214+
conn, err := net.DialTimeout("tcp", addr, time.Second)
215+
if err == nil {
216+
conn.Close()
217+
return nil
218+
}
219+
}
220+
}
221+
}
222+
223+
// APIClientNoKeepAlive creates an API client that doesn't reuse connections.
224+
// This is useful after server restarts where existing connections may be stale.
225+
func (c *TestContainer) APIClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error) {
226+
transport := &http.Transport{
227+
DisableKeepAlives: true,
228+
}
229+
httpClient := &http.Client{Transport: transport}
230+
return instanceoapi.NewClientWithResponses(c.APIBaseURL(), instanceoapi.WithHTTPClient(httpClient))
231+
}
232+
233+
// CDPAddr returns the TCP address for the container's DevTools proxy.
234+
func (c *TestContainer) CDPAddr() string {
235+
return fmt.Sprintf("127.0.0.1:%d", c.CDPPort)
236+
}

0 commit comments

Comments
 (0)