diff --git a/server/Makefile b/server/Makefile index d9952048..733c5b7d 100644 --- a/server/Makefile +++ b/server/Makefile @@ -35,9 +35,8 @@ dev: build $(RECORDING_DIR) test: go vet ./... - # Run tests sequentially (-p 1) to avoid port conflicts in e2e tests - # (all e2e tests bind to the same ports: 10001, 9222) - go test -v -race -p 1 ./... + # E2E tests use dynamic ports via TestContainer, enabling parallel execution + go test -v -race ./... clean: @rm -rf $(BIN_DIR) diff --git a/server/e2e/container.go b/server/e2e/container.go new file mode 100644 index 00000000..ce7259e0 --- /dev/null +++ b/server/e2e/container.go @@ -0,0 +1,219 @@ +package e2e + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// TestContainer wraps testcontainers-go to manage a Docker container for e2e tests. +// This enables parallel test execution by giving each test its own dynamically allocated ports. +type TestContainer struct { + Name string + Image string + APIPort int // dynamically allocated host port -> container 10001 + CDPPort int // dynamically allocated host port -> container 9222 + ctr testcontainers.Container +} + +// ContainerConfig holds optional configuration for container startup. +type ContainerConfig struct { + Env map[string]string + HostAccess bool // Add host.docker.internal mapping +} + +// NewTestContainer creates a new test container placeholder. +// The actual container is started when Start() is called. +// Works with both *testing.T and *testing.B (any testing.TB). +func NewTestContainer(tb testing.TB, image string) *TestContainer { + tb.Helper() + return &TestContainer{ + Image: image, + } +} + +// Start starts the container with the given configuration using testcontainers-go. +func (c *TestContainer) Start(ctx context.Context, cfg ContainerConfig) error { + // Build environment variables + env := make(map[string]string) + for k, v := range cfg.Env { + env[k] = v + } + // Ensure CHROMIUM_FLAGS includes --no-sandbox for CI + if flags, ok := env["CHROMIUM_FLAGS"]; !ok { + env["CHROMIUM_FLAGS"] = "--no-sandbox" + } else if flags != "" { + env["CHROMIUM_FLAGS"] = flags + " --no-sandbox" + } else { + env["CHROMIUM_FLAGS"] = "--no-sandbox" + } + + // Build container request options + opts := []testcontainers.ContainerCustomizer{ + testcontainers.WithImage(c.Image), + testcontainers.WithExposedPorts("10001/tcp", "9222/tcp"), + testcontainers.WithEnv(env), + testcontainers.WithTmpfs(map[string]string{"/dev/shm": "size=2g,mode=1777"}), + // Set privileged mode for Chrome + testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) { + hc.Privileged = true + }), + // Wait for the API to be ready + testcontainers.WithWaitStrategy( + wait.ForHTTP("/spec.yaml"). + WithPort("10001/tcp"). + WithStartupTimeout(2 * time.Minute), + ), + } + + // Add host access if requested + if cfg.HostAccess { + opts = append(opts, testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) { + hc.ExtraHosts = append(hc.ExtraHosts, "host.docker.internal:host-gateway") + })) + } + + // Start container + ctr, err := testcontainers.Run(ctx, c.Image, opts...) + if err != nil { + return fmt.Errorf("failed to start container: %w", err) + } + c.ctr = ctr + + // Get container name + inspect, err := ctr.Inspect(ctx) + if err == nil { + c.Name = inspect.Name + } + + // Get mapped ports + apiPort, err := ctr.MappedPort(ctx, "10001/tcp") + if err != nil { + return fmt.Errorf("failed to get API port: %w", err) + } + c.APIPort = apiPort.Int() + + cdpPort, err := ctr.MappedPort(ctx, "9222/tcp") + if err != nil { + return fmt.Errorf("failed to get CDP port: %w", err) + } + c.CDPPort = cdpPort.Int() + + return nil +} + +// Stop stops and removes the container. +func (c *TestContainer) Stop(ctx context.Context) error { + if c.ctr == nil { + return nil + } + return testcontainers.TerminateContainer(c.ctr) +} + +// APIBaseURL returns the URL for the container's API server. +func (c *TestContainer) APIBaseURL() string { + return fmt.Sprintf("http://127.0.0.1:%d", c.APIPort) +} + +// CDPURL returns the WebSocket URL for the container's DevTools proxy. +func (c *TestContainer) CDPURL() string { + return fmt.Sprintf("ws://127.0.0.1:%d/", c.CDPPort) +} + +// APIClient creates an OpenAPI client for this container's API. +func (c *TestContainer) APIClient() (*instanceoapi.ClientWithResponses, error) { + return instanceoapi.NewClientWithResponses(c.APIBaseURL()) +} + +// WaitReady waits for the container's API to become ready. +// Note: With testcontainers-go, this is usually handled by the wait strategy in Start(). +// This method is kept for compatibility and performs an additional health check. +func (c *TestContainer) WaitReady(ctx context.Context) error { + url := c.APIBaseURL() + "/spec.yaml" + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + client := &http.Client{Timeout: 2 * time.Second} + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + resp, err := client.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + +// ExitCh returns a channel that receives when the container exits. +// Note: testcontainers-go handles this internally; this is kept for API compatibility. +func (c *TestContainer) ExitCh() <-chan error { + ch := make(chan error, 1) + // testcontainers-go doesn't expose an exit channel directly + // Return a channel that never fires - container lifecycle is managed by testcontainers + return ch +} + +// WaitDevTools waits for the CDP WebSocket endpoint to be ready. +func (c *TestContainer) WaitDevTools(ctx context.Context) error { + return wait.ForListeningPort(nat.Port("9222/tcp")). + WithStartupTimeout(2 * time.Minute). + WaitUntilReady(ctx, c.ctr) +} + +// APIClientNoKeepAlive creates an API client that doesn't reuse connections. +// This is useful after server restarts where existing connections may be stale. +func (c *TestContainer) APIClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error) { + transport := &http.Transport{ + DisableKeepAlives: true, + } + httpClient := &http.Client{Transport: transport} + return instanceoapi.NewClientWithResponses(c.APIBaseURL(), instanceoapi.WithHTTPClient(httpClient)) +} + +// CDPAddr returns the TCP address for the container's DevTools proxy. +func (c *TestContainer) CDPAddr() string { + return fmt.Sprintf("127.0.0.1:%d", c.CDPPort) +} + +// Exec executes a command inside the container and returns the combined output. +func (c *TestContainer) Exec(ctx context.Context, cmd []string) (int, string, error) { + exitCode, reader, err := c.ctr.Exec(ctx, cmd) + if err != nil { + return exitCode, "", err + } + + // Read all output + buf := make([]byte, 0) + tmp := make([]byte, 1024) + for { + n, err := reader.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil { + break + } + } + + return exitCode, string(buf), nil +} + +// Container returns the underlying testcontainers.Container for advanced usage. +func (c *TestContainer) Container() testcontainers.Container { + return c.ctr +} diff --git a/server/e2e/e2e_chromium_restart_bench_test.go b/server/e2e/e2e_chromium_restart_bench_test.go index fa07eba9..a1b09811 100644 --- a/server/e2e/e2e_chromium_restart_bench_test.go +++ b/server/e2e/e2e_chromium_restart_bench_test.go @@ -4,13 +4,11 @@ import ( "context" "encoding/base64" "fmt" - "log/slog" "net/http" "os/exec" "testing" "time" - logctx "github.com/onkernel/kernel-images/server/lib/logger" instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/stretchr/testify/require" ) @@ -43,48 +41,41 @@ func BenchmarkChromiumRestart(b *testing.B) { } func runChromiumRestartBenchmark(b *testing.B, image, imageType string) { - name := fmt.Sprintf("%s-restart-bench-%s", containerName, imageType) - - logger := slog.New(slog.NewTextHandler(b.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) - - // Clean slate - _ = stopContainer(baseCtx, name) + c := NewTestContainer(b, image) env := map[string]string{ "WIDTH": "1024", "HEIGHT": "768", } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - if err != nil { + if err := c.Start(ctx, ContainerConfig{Env: env}); err != nil { b.Fatalf("failed to start container: %v", err) } - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) - defer cancel() + defer c.Stop(ctx) - logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") - if err := waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh); err != nil { + b.Logf("[setup] waiting for API at %s/spec.yaml", c.APIBaseURL()) + if err := c.WaitReady(ctx); err != nil { b.Fatalf("api not ready: %v", err) } // Wait for initial DevTools to be ready - logger.Info("[setup]", "action", "waiting for DevTools") - if err := waitTCP(ctx, "127.0.0.1:9222"); err != nil { + b.Logf("[setup] waiting for DevTools at %s", c.CDPAddr()) + if err := c.WaitDevTools(ctx); err != nil { b.Fatalf("DevTools not ready: %v", err) } - client, err := apiClient() + client, err := c.APIClient() if err != nil { b.Fatalf("failed to create API client: %v", err) } // Warmup - do one restart cycle to ensure everything is ready - logger.Info("[warmup]", "action", "performing warmup restart") - if err := doChromiumRestart(ctx, client, logger); err != nil { + b.Log("[warmup] performing warmup restart") + if err := doChromiumRestart(ctx, client, b); err != nil { b.Fatalf("warmup restart failed: %v", err) } @@ -94,7 +85,7 @@ func runChromiumRestartBenchmark(b *testing.B, image, imageType string) { var totalStopTime, totalStartTime, totalDevToolsTime time.Duration for i := 0; i < b.N; i++ { - stopTime, startTime, devtoolsTime, err := measureChromiumRestartCycle(ctx, client, logger) + stopTime, startTime, devtoolsTime, err := measureChromiumRestartCycle(ctx, client, b) if err != nil { b.Fatalf("restart cycle %d failed: %v", i, err) } @@ -103,12 +94,12 @@ func runChromiumRestartBenchmark(b *testing.B, image, imageType string) { totalStartTime += startTime totalDevToolsTime += devtoolsTime - logger.Info("[iteration]", - "i", i, - "stop_ms", stopTime.Milliseconds(), - "start_ms", startTime.Milliseconds(), - "devtools_ms", devtoolsTime.Milliseconds(), - "total_ms", (stopTime + startTime + devtoolsTime).Milliseconds(), + b.Logf("[iteration] i=%d stop_ms=%d start_ms=%d devtools_ms=%d total_ms=%d", + i, + stopTime.Milliseconds(), + startTime.Milliseconds(), + devtoolsTime.Milliseconds(), + (stopTime + startTime + devtoolsTime).Milliseconds(), ) } @@ -126,20 +117,20 @@ func runChromiumRestartBenchmark(b *testing.B, image, imageType string) { b.ReportMetric(float64(avgDevTools.Milliseconds()), "devtools_ms/op") b.ReportMetric(float64(avgTotal.Milliseconds()), "total_ms/op") - logger.Info("[summary]", - "image", imageType, - "iterations", b.N, - "avg_stop_ms", avgStop.Milliseconds(), - "avg_start_ms", avgStart.Milliseconds(), - "avg_devtools_ms", avgDevTools.Milliseconds(), - "avg_total_ms", avgTotal.Milliseconds(), + b.Logf("[summary] image=%s iterations=%d avg_stop_ms=%d avg_start_ms=%d avg_devtools_ms=%d avg_total_ms=%d", + imageType, + b.N, + avgStop.Milliseconds(), + avgStart.Milliseconds(), + avgDevTools.Milliseconds(), + avgTotal.Milliseconds(), ) } } // measureChromiumRestartCycle performs a full stop/start cycle and returns timing for each phase. // Returns: stopTime, startTime, devtoolsReadyTime, error -func measureChromiumRestartCycle(ctx context.Context, client *instanceoapi.ClientWithResponses, logger *slog.Logger) (time.Duration, time.Duration, time.Duration, error) { +func measureChromiumRestartCycle(ctx context.Context, client *instanceoapi.ClientWithResponses, tb testing.TB) (time.Duration, time.Duration, time.Duration, error) { // Phase 1: Stop chromium stopStart := time.Now() stopDuration, err := execSupervisorctl(ctx, client, "stop", "chromium") @@ -255,7 +246,7 @@ func waitForDevToolsReady(ctx context.Context, client *instanceoapi.ClientWithRe } // doChromiumRestart performs a full restart cycle (for warmup). -func doChromiumRestart(ctx context.Context, client *instanceoapi.ClientWithResponses, logger *slog.Logger) error { +func doChromiumRestart(ctx context.Context, client *instanceoapi.ClientWithResponses, tb testing.TB) error { args := []string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"} req := instanceoapi.ProcessExecJSONRequestBody{ Command: "supervisorctl", @@ -294,45 +285,38 @@ func TestChromiumRestartTiming(t *testing.T) { for _, img := range images { t.Run(img.name, func(t *testing.T) { - name := fmt.Sprintf("%s-restart-timing-%s", containerName, img.name) - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) - - // Clean slate - _ = stopContainer(baseCtx, name) + c := NewTestContainer(t, img.image) env := map[string]string{ "WIDTH": "1024", "HEIGHT": "768", } - // Start container - _, exitCh, err := runContainer(baseCtx, img.image, name, env) - require.NoError(t, err, "failed to start container") - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - t.Logf("Waiting for API...") - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready") + // Start container + require.NoError(t, c.Start(ctx, ContainerConfig{Env: env}), "failed to start container") + defer c.Stop(ctx) + + t.Logf("Waiting for API at %s...", c.APIBaseURL()) + require.NoError(t, c.WaitReady(ctx), "api not ready") - t.Logf("Waiting for DevTools...") - require.NoError(t, waitTCP(ctx, "127.0.0.1:9222"), "DevTools not ready") + t.Logf("Waiting for DevTools at %s...", c.CDPAddr()) + require.NoError(t, c.WaitDevTools(ctx), "DevTools not ready") - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // Warmup t.Logf("Performing warmup restart...") - require.NoError(t, doChromiumRestart(ctx, client, logger), "warmup restart failed") + require.NoError(t, doChromiumRestart(ctx, client, t), "warmup restart failed") // Collect timing data var stopTimes, startTimes, devtoolsTimes []time.Duration for i := 0; i < iterations; i++ { - stopTime, startTime, devtoolsTime, err := measureChromiumRestartCycle(ctx, client, logger) + stopTime, startTime, devtoolsTime, err := measureChromiumRestartCycle(ctx, client, t) require.NoError(t, err, "restart cycle %d failed", i) stopTimes = append(stopTimes, stopTime) diff --git a/server/e2e/e2e_combined_flow_test.go b/server/e2e/e2e_combined_flow_test.go index 69852085..5d0f6d08 100644 --- a/server/e2e/e2e_combined_flow_test.go +++ b/server/e2e/e2e_combined_flow_test.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "log/slog" "mime/multipart" "net/http" "os" @@ -16,7 +15,6 @@ import ( "time" "github.com/coder/websocket" - logctx "github.com/onkernel/kernel-images/server/lib/logger" instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/stretchr/testify/require" ) @@ -27,135 +25,121 @@ import ( // This reproduces the race condition where profile loading fails to connect to CDP // after the sequence: extension upload (restart) -> viewport change (restart) -> CDP connect. func TestExtensionViewportThenCDPConnection(t *testing.T) { - image := headlessImage - name := containerName + "-combined-flow" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { require.NoError(t, err, "docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) - // Start with specific resolution to verify viewport change works env := map[string]string{ "WIDTH": "1024", "HEIGHT": "768", } - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + c := NewTestContainer(t, headlessImage) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + err := c.Start(ctx, ContainerConfig{Env: env}) + require.NoError(t, err, "failed to start container: %v", err) + defer c.Stop(ctx) + + t.Logf("[setup] waiting for API: %s/spec.yaml", c.APIBaseURL()) + require.NoError(t, c.WaitReady(ctx), "api not ready") // Wait for DevTools to be ready initially - _, err = waitDevtoolsWS(ctx) + err = c.WaitDevTools(ctx) require.NoError(t, err, "devtools not ready initially") - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // Step 1: Upload extension (triggers Chromium restart) - logger.Info("[test]", "step", 1, "action", "uploading extension") - uploadExtension(t, ctx, client, logger) + t.Log("[test] step 1: uploading extension") + uploadExtension(t, ctx, client) // Wait briefly for the system to stabilize after extension upload restart // The extension upload waits for DevTools, but the API may need a moment - logger.Info("[test]", "action", "verifying API is still responsive after extension upload") - err = waitForAPIHealth(ctx, logger) + t.Log("[test] verifying API is still responsive after extension upload") + err = waitForAPIHealth(ctx, c.APIBaseURL(), t) require.NoError(t, err, "API not healthy after extension upload") // Create a fresh API client to avoid connection reuse issues after restart // The previous client's connection may have been closed by the server - client, err = apiClientNoKeepAlive() + client, err = c.APIClientNoKeepAlive() require.NoError(t, err, "failed to create fresh API client") // Step 2: Change viewport (triggers another Chromium restart) - logger.Info("[test]", "step", 2, "action", "changing viewport to 1920x1080") - changeViewport(t, ctx, client, 1920, 1080, logger) + t.Log("[test] step 2: changing viewport to 1920x1080") + changeViewport(t, ctx, client, 1920, 1080) // Wait for API to be healthy after viewport change - logger.Info("[test]", "action", "verifying API is still responsive after viewport change") - err = waitForAPIHealth(ctx, logger) + t.Log("[test] verifying API is still responsive after viewport change") + err = waitForAPIHealth(ctx, c.APIBaseURL(), t) require.NoError(t, err, "API not healthy after viewport change") // Step 3: Immediately attempt CDP connection (this may fail due to race condition) - logger.Info("[test]", "step", 3, "action", "attempting CDP connection immediately after restarts") + t.Log("[test] step 3: attempting CDP connection immediately after restarts") // Try connecting without any delay - this is the most aggressive test case - err = attemptCDPConnection(ctx, logger) + err = attemptCDPConnection(ctx, c.CDPURL(), t) if err != nil { - logger.Error("[test]", "step", 3, "result", "CDP connection failed", "error", err.Error()) + t.Logf("[test] step 3: CDP connection failed: %v", err) // Log additional diagnostics - logCDPDiagnostics(ctx, logger) + logCDPDiagnostics(ctx, t) } require.NoError(t, err, "CDP connection failed after extension upload + viewport change") - logger.Info("[test]", "result", "CDP connection successful after back-to-back restarts") + t.Log("[test] result: CDP connection successful after back-to-back restarts") } // TestMultipleCDPConnectionsAfterRestart tests that multiple rapid CDP connections // work correctly after Chromium restart. func TestMultipleCDPConnectionsAfterRestart(t *testing.T) { - image := headlessImage - name := containerName + "-multi-cdp" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { require.NoError(t, err, "docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) - env := map[string]string{} - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + c := NewTestContainer(t, headlessImage) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - logger.Info("[setup]", "action", "waiting for API") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + err := c.Start(ctx, ContainerConfig{Env: env}) + require.NoError(t, err, "failed to start container: %v", err) + defer c.Stop(ctx) + + t.Log("[setup] waiting for API") + require.NoError(t, c.WaitReady(ctx), "api not ready") - _, err = waitDevtoolsWS(ctx) + err = c.WaitDevTools(ctx) require.NoError(t, err, "devtools not ready initially") - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // Upload extension to trigger a restart - logger.Info("[test]", "action", "uploading extension to trigger restart") - uploadExtension(t, ctx, client, logger) + t.Log("[test] uploading extension to trigger restart") + uploadExtension(t, ctx, client) // Rapidly attempt multiple CDP connections in sequence - logger.Info("[test]", "action", "attempting 5 rapid CDP connections") + t.Log("[test] attempting 5 rapid CDP connections") for i := 1; i <= 5; i++ { - logger.Info("[test]", "connection_attempt", i) - err := attemptCDPConnection(ctx, logger) + t.Logf("[test] connection attempt %d", i) + err := attemptCDPConnection(ctx, c.CDPURL(), t) require.NoError(t, err, "CDP connection %d failed", i) - logger.Info("[test]", "connection_attempt", i, "result", "success") + t.Logf("[test] connection attempt %d: success", i) } - logger.Info("[test]", "result", "all CDP connections successful") + t.Log("[test] result: all CDP connections successful") } // uploadExtension uploads a simple MV3 extension and waits for Chromium to restart. -func uploadExtension(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, logger *slog.Logger) { +func uploadExtension(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses) { t.Helper() // Build simple MV3 extension zip in-memory @@ -189,11 +173,11 @@ func uploadExtension(t *testing.T, ctx context.Context, client *instanceoapi.Cli elapsed := time.Since(start) require.NoError(t, err, "uploadExtensionsAndRestart request error") require.Equal(t, http.StatusCreated, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) - logger.Info("[extension]", "action", "uploaded", "elapsed", elapsed.String()) + t.Logf("[extension] uploaded in %s", elapsed) } // changeViewport changes the display resolution, which triggers Chromium restart. -func changeViewport(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, width, height int, logger *slog.Logger) { +func changeViewport(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses, width, height int) { t.Helper() req := instanceoapi.PatchDisplayJSONRequestBody{ @@ -206,18 +190,18 @@ func changeViewport(t *testing.T, ctx context.Context, client *instanceoapi.Clie require.NoError(t, err, "PATCH /display request failed") require.Equal(t, http.StatusOK, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) require.NotNil(t, rsp.JSON200, "expected JSON200 response") - logger.Info("[viewport]", "action", "changed", "width", width, "height", height, "elapsed", elapsed.String()) + t.Logf("[viewport] changed to %dx%d in %s", width, height, elapsed) } // attemptCDPConnection tries to establish a CDP WebSocket connection and run a simple command. -func attemptCDPConnection(ctx context.Context, logger *slog.Logger) error { - wsURL := "ws://127.0.0.1:9222/" +func attemptCDPConnection(ctx context.Context, wsURL string, t *testing.T) error { + t.Helper() // Set a timeout for the connection attempt connCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - logger.Info("[cdp]", "action", "connecting", "url", wsURL) + t.Logf("[cdp] connecting to %s", wsURL) // Establish WebSocket connection to CDP proxy conn, _, err := websocket.Dial(connCtx, wsURL, nil) @@ -226,7 +210,7 @@ func attemptCDPConnection(ctx context.Context, logger *slog.Logger) error { } defer conn.Close(websocket.StatusNormalClosure, "") - logger.Info("[cdp]", "action", "connected", "url", wsURL) + t.Logf("[cdp] connected to %s", wsURL) // Send a simple CDP command: Browser.getVersion // This validates that the proxy can communicate with the browser @@ -239,7 +223,7 @@ func attemptCDPConnection(ctx context.Context, logger *slog.Logger) error { return fmt.Errorf("failed to marshal CDP request: %w", err) } - logger.Info("[cdp]", "action", "sending Browser.getVersion") + t.Log("[cdp] sending Browser.getVersion") if err := conn.Write(connCtx, websocket.MessageText, reqBytes); err != nil { return fmt.Errorf("failed to send CDP command: %w", err) @@ -269,25 +253,17 @@ func attemptCDPConnection(ctx context.Context, logger *slog.Logger) error { // Log some version info for debugging if product, ok := result["product"].(string); ok { - logger.Info("[cdp]", "action", "version received", "product", product) + t.Logf("[cdp] version received: %s", product) } - logger.Info("[cdp]", "action", "command successful") + t.Log("[cdp] command successful") return nil } -// apiClientNoKeepAlive creates an API client that doesn't reuse connections. -// This is useful after server restarts where existing connections may be stale. -func apiClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error) { - transport := &http.Transport{ - DisableKeepAlives: true, - } - httpClient := &http.Client{Transport: transport} - return instanceoapi.NewClientWithResponses(apiBaseURL, instanceoapi.WithHTTPClient(httpClient)) -} - // waitForAPIHealth waits until the API server is responsive. -func waitForAPIHealth(ctx context.Context, logger *slog.Logger) error { +func waitForAPIHealth(ctx context.Context, apiBaseURL string, t *testing.T) error { + t.Helper() + client := &http.Client{Timeout: 5 * time.Second} maxAttempts := 30 for i := 0; i < maxAttempts; i++ { @@ -295,7 +271,7 @@ func waitForAPIHealth(ctx context.Context, logger *slog.Logger) error { resp, err := client.Do(req) if err == nil && resp.StatusCode == http.StatusOK { resp.Body.Close() - logger.Info("[health]", "action", "API healthy", "attempts", i+1) + t.Logf("[health] API healthy after %d attempts", i+1) return nil } if resp != nil && resp.Body != nil { @@ -309,28 +285,30 @@ func waitForAPIHealth(ctx context.Context, logger *slog.Logger) error { } // logCDPDiagnostics logs diagnostic information when CDP connection fails. -func logCDPDiagnostics(ctx context.Context, logger *slog.Logger) { +func logCDPDiagnostics(ctx context.Context, t *testing.T) { + t.Helper() + // Try to get the internal CDP endpoint status stdout, err := execCombinedOutput(ctx, "curl", []string{"-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:9223/json/version"}) if err != nil { - logger.Info("[diagnostics]", "internal_cdp_status", "failed", "error", err.Error()) + t.Logf("[diagnostics] internal CDP status: failed (%v)", err) } else { - logger.Info("[diagnostics]", "internal_cdp_status", stdout) + t.Logf("[diagnostics] internal CDP status: %s", stdout) } // Check if Chromium process is running psOutput, err := execCombinedOutput(ctx, "pgrep", []string{"-a", "chromium"}) if err != nil { - logger.Info("[diagnostics]", "chromium_process", "not found or error", "error", err.Error()) + t.Logf("[diagnostics] chromium process: not found or error (%v)", err) } else { - logger.Info("[diagnostics]", "chromium_process", psOutput) + t.Logf("[diagnostics] chromium process: %s", psOutput) } // Check supervisord status supervisorOutput, err := execCombinedOutput(ctx, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "status"}) if err != nil { - logger.Info("[diagnostics]", "supervisor_status", "error", "error", err.Error()) + t.Logf("[diagnostics] supervisor status: error (%v)", err) } else { - logger.Info("[diagnostics]", "supervisor_status", supervisorOutput) + t.Logf("[diagnostics] supervisor status: %s", supervisorOutput) } } diff --git a/server/e2e/e2e_enterprise_extension_test.go b/server/e2e/e2e_enterprise_extension_test.go index 37b36010..6baf8319 100644 --- a/server/e2e/e2e_enterprise_extension_test.go +++ b/server/e2e/e2e_enterprise_extension_test.go @@ -3,10 +3,10 @@ package e2e import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" - "log/slog" "mime/multipart" "net/http" "os" @@ -16,7 +16,7 @@ import ( "testing" "time" - logctx "github.com/onkernel/kernel-images/server/lib/logger" + instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/stretchr/testify/require" ) @@ -32,6 +32,7 @@ import ( // This test uses a real built extension (web-bot-auth) to reproduce production behavior. // It runs against both headless and headful Chrome images. func TestEnterpriseExtensionInstallation(t *testing.T) { + t.Parallel() ensurePlaywrightDeps(t) testCases := []struct { @@ -43,119 +44,109 @@ func TestEnterpriseExtensionInstallation(t *testing.T) { } for _, tc := range testCases { + tc := tc // capture range variable t.Run(tc.name, func(t *testing.T) { + t.Parallel() runEnterpriseExtensionTest(t, tc.image) }) } } func runEnterpriseExtensionTest(t *testing.T, image string) { - name := containerName + "-enterprise-ext" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) - baseCtx := logctx.AddToContext(context.Background(), logger) - if _, err := exec.LookPath("docker"); err != nil { require.NoError(t, err, "docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) + // Create and start container with dynamic ports + c := NewTestContainer(t, image) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() // Use default CHROMIUM_FLAGS - the images now have --disable-background-networking removed // (headless) or never had it (headful), allowing Chrome to fetch extensions via // ExtensionInstallForcelist enterprise policy - env := map[string]string{} - - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) - ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) - defer cancel() - - logger.Info("[setup]", "action", "waiting for API", "image", image, "url", apiBaseURL+"/spec.yaml") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + t.Logf("[setup] waiting for API image=%s url=%s/spec.yaml", image, c.APIBaseURL()) + require.NoError(t, c.WaitReady(ctx), "api not ready") // Wait for DevTools to be ready - _, err = waitDevtoolsWS(ctx) - require.NoError(t, err, "devtools not ready") + require.NoError(t, c.WaitDevTools(ctx), "devtools not ready") // First upload a simple extension to simulate the kernel extension in production. // This causes Chrome to be launched with --load-extension, which mirrors production // where the kernel extension is always loaded before any enterprise extensions. - logger.Info("[test]", "action", "uploading kernel-like extension first (to simulate prod)") - uploadKernelLikeExtension(t, ctx, logger) + t.Log("[test] uploading kernel-like extension first (to simulate prod)") + uploadKernelLikeExtension(t, ctx, c) // Wait for Chrome to restart with the new flags time.Sleep(3 * time.Second) - _, err = waitDevtoolsWS(ctx) - require.NoError(t, err, "devtools not ready after kernel extension") + require.NoError(t, c.WaitDevTools(ctx), "devtools not ready after kernel extension") // Upload the enterprise test extension (with update.xml and .crx) - logger.Info("[test]", "action", "uploading enterprise test extension (with update.xml and .crx)") - uploadEnterpriseTestExtension(t, ctx, logger) + t.Log("[test] uploading enterprise test extension (with update.xml and .crx)") + uploadEnterpriseTestExtension(t, ctx, c) // Wait a bit for Chrome to process the enterprise policy - logger.Info("[test]", "action", "waiting for Chrome to process enterprise policy") + t.Log("[test] waiting for Chrome to process enterprise policy") time.Sleep(5 * time.Second) // Check what files were extracted on the server - logger.Info("[test]", "action", "checking extracted extension files on server") - checkExtractedFiles(t, ctx, logger) + t.Log("[test] checking extracted extension files on server") + checkExtractedFiles(t, ctx, c) // Check the kernel-images-api logs for extension download requests - logger.Info("[test]", "action", "checking if Chrome fetched the extension") - checkExtensionDownloadLogs(t, ctx, logger) + t.Log("[test] checking if Chrome fetched the extension") + checkExtensionDownloadLogs(t, ctx, c) // Verify enterprise policy was configured correctly - logger.Info("[test]", "action", "verifying enterprise policy configuration") - verifyEnterprisePolicy(t, ctx, logger) + t.Log("[test] verifying enterprise policy configuration") + verifyEnterprisePolicy(t, ctx, c) // Wait longer and check again if Chrome has downloaded the extension - logger.Info("[test]", "action", "waiting for Chrome to download extension via enterprise policy") + t.Log("[test] waiting for Chrome to download extension via enterprise policy") time.Sleep(30 * time.Second) // Check logs again - checkExtensionDownloadLogs(t, ctx, logger) + checkExtensionDownloadLogs(t, ctx, c) // Check Chrome's extension installation logs - logger.Info("[test]", "action", "checking Chrome stderr for extension-related logs") - checkChromiumLogs(t, ctx, logger) + t.Log("[test] checking Chrome stderr for extension-related logs") + checkChromiumLogs(t, ctx, c) // Try to trigger extension installation by restarting Chrome - logger.Info("[test]", "action", "restarting Chrome to trigger policy refresh") - restartChrome(t, ctx, logger) + t.Log("[test] restarting Chrome to trigger policy refresh") + restartChrome(t, ctx, c) time.Sleep(15 * time.Second) // Check logs one more time - checkExtensionDownloadLogs(t, ctx, logger) - checkChromiumLogs(t, ctx, logger) + checkExtensionDownloadLogs(t, ctx, c) + checkChromiumLogs(t, ctx, c) // Check Chrome's policy state - logger.Info("[test]", "action", "checking Chrome policy state") - checkChromePolicies(t, ctx, logger) + t.Log("[test] checking Chrome policy state") + checkChromePolicies(t, ctx, c) // Check chrome://policy to see if Chrome recognizes the policy - logger.Info("[test]", "action", "checking chrome://policy via screenshot") - takeChromePolicyScreenshot(t, ctx, logger) + t.Log("[test] checking chrome://policy via screenshot") + takeChromePolicyScreenshot(t, ctx, c) // Verify the extension is installed - logger.Info("[test]", "action", "checking if extension is installed in Chrome's user-data") - verifyExtensionInstalled(t, ctx, logger) + t.Log("[test] checking if extension is installed in Chrome's user-data") + verifyExtensionInstalled(t, ctx, c) - logger.Info("[test]", "result", "enterprise extension installation test completed") + t.Log("[test] enterprise extension installation test completed") } // uploadKernelLikeExtension uploads a simple extension to simulate the kernel extension. // In production, the kernel extension is always loaded before any enterprise extensions, // so this ensures the test mirrors that behavior. -func uploadKernelLikeExtension(t *testing.T, ctx context.Context, logger *slog.Logger) { +func uploadKernelLikeExtension(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // Get the path to the simple test extension (no webRequest, so no enterprise policy) @@ -187,15 +178,15 @@ func uploadKernelLikeExtension(t *testing.T, ctx context.Context, logger *slog.L "expected 201 Created but got %d. Body: %s", rsp.StatusCode(), string(rsp.Body)) - logger.Info("[kernel-ext]", "action", "uploaded kernel-like extension", "elapsed", elapsed.String()) + t.Logf("[kernel-ext] uploaded kernel-like extension elapsed=%s", elapsed.String()) } // uploadEnterpriseTestExtension uploads the test extension with update.xml and .crx files. // This should trigger enterprise policy handling via ExtensionInstallForcelist. -func uploadEnterpriseTestExtension(t *testing.T, ctx context.Context, logger *slog.Logger) { +func uploadEnterpriseTestExtension(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // Get the path to the test extension @@ -206,19 +197,19 @@ func uploadEnterpriseTestExtension(t *testing.T, ctx context.Context, logger *sl manifestPath := filepath.Join(extDir, "manifest.json") manifestData, err := os.ReadFile(manifestPath) require.NoError(t, err, "failed to read manifest.json") - logger.Info("[extension]", "manifest", string(manifestData)) + t.Logf("[extension] manifest=%s", string(manifestData)) // Read and log the update.xml updateXMLPath := filepath.Join(extDir, "update.xml") updateXMLData, err := os.ReadFile(updateXMLPath) require.NoError(t, err, "failed to read update.xml") - logger.Info("[extension]", "update.xml", string(updateXMLData)) + t.Logf("[extension] update.xml=%s", string(updateXMLData)) // Verify .crx exists crxPath := filepath.Join(extDir, "extension.crx") crxInfo, err := os.Stat(crxPath) require.NoError(t, err, "failed to stat .crx file") - logger.Info("[extension]", "crx_size", crxInfo.Size()) + t.Logf("[extension] crx_size=%d", crxInfo.Size()) // Create zip of the extension extZip, err := zipDirToBytes(extDir) @@ -246,17 +237,17 @@ func uploadEnterpriseTestExtension(t *testing.T, ctx context.Context, logger *sl "expected 201 Created but got %d. Body: %s", rsp.StatusCode(), string(rsp.Body)) - logger.Info("[extension]", "action", "uploaded", "elapsed", elapsed.String()) + t.Logf("[extension] uploaded elapsed=%s", elapsed.String()) } // verifyEnterprisePolicy checks that the enterprise policy was configured correctly. -func verifyEnterprisePolicy(t *testing.T, ctx context.Context, logger *slog.Logger) { +func verifyEnterprisePolicy(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() // Read policy.json - policyContent, err := execCombinedOutput(ctx, "cat", []string{"/etc/chromium/policies/managed/policy.json"}) + policyContent, err := execCombinedOutputWithClient(ctx, c, "cat", []string{"/etc/chromium/policies/managed/policy.json"}) require.NoError(t, err, "failed to read policy.json") - logger.Info("[policy]", "content", policyContent) + t.Logf("[policy] content=%s", policyContent) var policy map[string]interface{} err = json.Unmarshal([]byte(policyContent), &policy) @@ -269,7 +260,7 @@ func verifyEnterprisePolicy(t *testing.T, ctx context.Context, logger *slog.Logg // Log all entries for i, entry := range extensionInstallForcelist { - logger.Info("[policy]", "forcelist_entry", i, "value", entry) + t.Logf("[policy] forcelist_entry=%d value=%v", i, entry) } // Find the enterprise-test entry @@ -277,7 +268,7 @@ func verifyEnterprisePolicy(t *testing.T, ctx context.Context, logger *slog.Logg for _, entry := range extensionInstallForcelist { if entryStr, ok := entry.(string); ok && strings.Contains(entryStr, "enterprise-test") { found = true - logger.Info("[policy]", "found_entry", entryStr) + t.Logf("[policy] found_entry=%s", entryStr) break } } @@ -286,97 +277,97 @@ func verifyEnterprisePolicy(t *testing.T, ctx context.Context, logger *slog.Logg // Check ExtensionSettings extensionSettings, ok := policy["ExtensionSettings"].(map[string]interface{}) if ok { - logger.Info("[policy]", "extension_settings", fmt.Sprintf("%+v", extensionSettings)) + t.Logf("[policy] extension_settings=%+v", extensionSettings) } } // checkExtractedFiles checks what files were extracted on the server side. -func checkExtractedFiles(t *testing.T, ctx context.Context, logger *slog.Logger) { +func checkExtractedFiles(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() // List all files in the extension directory - output, err := execCombinedOutput(ctx, "ls", []string{"-la", "/home/kernel/extensions/enterprise-test/"}) + output, err := execCombinedOutputWithClient(ctx, c, "ls", []string{"-la", "/home/kernel/extensions/enterprise-test/"}) if err != nil { - logger.Warn("[files]", "error", err.Error()) + t.Logf("[files] error=%v", err) } else { - logger.Info("[files]", "extension_dir", output) + t.Logf("[files] extension_dir=%s", output) } // Check if update.xml exists - updateXML, err := execCombinedOutput(ctx, "cat", []string{"/home/kernel/extensions/enterprise-test/update.xml"}) + updateXML, err := execCombinedOutputWithClient(ctx, c, "cat", []string{"/home/kernel/extensions/enterprise-test/update.xml"}) if err != nil { - logger.Warn("[files]", "update_xml_error", err.Error()) + t.Logf("[files] update_xml_error=%v", err) } else { - logger.Info("[files]", "update.xml", updateXML) + t.Logf("[files] update.xml=%s", updateXML) } // Check if .crx exists - crxOutput, err := execCombinedOutput(ctx, "ls", []string{"-la", "/home/kernel/extensions/enterprise-test/*.crx"}) + crxOutput, err := execCombinedOutputWithClient(ctx, c, "ls", []string{"-la", "/home/kernel/extensions/enterprise-test/*.crx"}) if err != nil { - logger.Warn("[files]", "crx_error", err.Error()) + t.Logf("[files] crx_error=%v", err) } else { - logger.Info("[files]", "crx_files", crxOutput) + t.Logf("[files] crx_files=%s", crxOutput) } // Check file types - fileOutput, err := execCombinedOutput(ctx, "file", []string{"/home/kernel/extensions/enterprise-test/extension.crx"}) + fileOutput, err := execCombinedOutputWithClient(ctx, c, "file", []string{"/home/kernel/extensions/enterprise-test/extension.crx"}) if err != nil { - logger.Warn("[files]", "file_type_error", err.Error()) + t.Logf("[files] file_type_error=%v", err) } else { - logger.Info("[files]", "crx_file_type", fileOutput) + t.Logf("[files] crx_file_type=%s", fileOutput) } } // checkExtensionDownloadLogs checks the kernel-images-api logs for extension download requests. -func checkExtensionDownloadLogs(t *testing.T, ctx context.Context, logger *slog.Logger) { +func checkExtensionDownloadLogs(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() // Check kernel-images-api log for requests to update.xml and .crx - apiLog, err := execCombinedOutput(ctx, "cat", []string{"/var/log/supervisord/kernel-images-api"}) + apiLog, err := execCombinedOutputWithClient(ctx, c, "cat", []string{"/var/log/supervisord/kernel-images-api"}) if err != nil { - logger.Warn("[logs]", "error", err.Error()) + t.Logf("[logs] error=%v", err) return } lines := strings.Split(apiLog, "\n") for _, line := range lines { if strings.Contains(line, "update.xml") || strings.Contains(line, ".crx") || strings.Contains(line, "extension") { - logger.Info("[logs]", "line", line) + t.Logf("[logs] line=%s", line) } } // Check specifically for GET requests to our extension if strings.Contains(apiLog, "GET") && strings.Contains(apiLog, "enterprise-test") { - logger.Info("[logs]", "result", "Chrome made GET requests to fetch the extension!") + t.Log("[logs] Chrome made GET requests to fetch the extension!") } else { - logger.Warn("[logs]", "result", "No GET requests to enterprise-test extension found") + t.Log("[logs] No GET requests to enterprise-test extension found") } // Log all GET requests for _, line := range lines { if strings.Contains(line, "GET") { - logger.Info("[logs]", "GET_request", line) + t.Logf("[logs] GET_request=%s", line) } } } // checkChromePolicies checks how Chrome sees the policies. -func checkChromePolicies(t *testing.T, ctx context.Context, logger *slog.Logger) { +func checkChromePolicies(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() // Check Chrome's local state for policy info - localState, err := execCombinedOutput(ctx, "cat", []string{"/home/kernel/user-data/Local State"}) + localState, err := execCombinedOutputWithClient(ctx, c, "cat", []string{"/home/kernel/user-data/Local State"}) if err != nil { - logger.Warn("[policies]", "local_state_error", err.Error()) + t.Logf("[policies] local_state_error=%v", err) } else { // Try to parse and look for extension-related info var state map[string]interface{} if err := json.Unmarshal([]byte(localState), &state); err != nil { - logger.Warn("[policies]", "parse_error", err.Error()) + t.Logf("[policies] parse_error=%v", err) } else { // Look for extensions in local state if ext, ok := state["extensions"]; ok { - logger.Info("[policies]", "extensions_in_local_state", fmt.Sprintf("%+v", ext)) + t.Logf("[policies] extensions_in_local_state=%+v", ext) } } } @@ -385,22 +376,22 @@ func checkChromePolicies(t *testing.T, ctx context.Context, logger *slog.Logger) // chrome://policy data could be extracted via CDP but that's complex // Instead, let's check if there's any extension component data extSettingsPath := "/home/kernel/user-data/Default/Extension Settings" - extSettings, err := execCombinedOutput(ctx, "ls", []string{"-la", extSettingsPath}) + extSettings, err := execCombinedOutputWithClient(ctx, c, "ls", []string{"-la", extSettingsPath}) if err != nil { - logger.Warn("[policies]", "ext_settings_dir_error", err.Error()) + t.Logf("[policies] ext_settings_dir_error=%v", err) } else { - logger.Info("[policies]", "ext_settings_dir", extSettings) + t.Logf("[policies] ext_settings_dir=%s", extSettings) } } // checkChromiumLogs checks Chrome's logs for extension-related messages. -func checkChromiumLogs(t *testing.T, ctx context.Context, logger *slog.Logger) { +func checkChromiumLogs(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() // Check chromium supervisor log for extension-related messages - chromiumLog, err := execCombinedOutput(ctx, "cat", []string{"/var/log/supervisord/chromium"}) + chromiumLog, err := execCombinedOutputWithClient(ctx, c, "cat", []string{"/var/log/supervisord/chromium"}) if err != nil { - logger.Warn("[chromium-log]", "error", err.Error()) + t.Logf("[chromium-log] error=%v", err) return } @@ -413,49 +404,42 @@ func checkChromiumLogs(t *testing.T, ctx context.Context, logger *slog.Logger) { strings.Contains(lowLine, "update") || strings.Contains(lowLine, "error") || strings.Contains(lowLine, "fail") { - logger.Info("[chromium-log]", "line", line) + t.Logf("[chromium-log] line=%s", line) } } // Also check stdout/stderr for the last 100 lines - logger.Info("[chromium-log]", "action", "checking last 100 lines of chromium log") - tailOutput, err := execCombinedOutput(ctx, "tail", []string{"-n", "100", "/var/log/supervisord/chromium"}) + t.Log("[chromium-log] checking last 100 lines of chromium log") + tailOutput, err := execCombinedOutputWithClient(ctx, c, "tail", []string{"-n", "100", "/var/log/supervisord/chromium"}) if err != nil { - logger.Warn("[chromium-log]", "tail_error", err.Error()) + t.Logf("[chromium-log] tail_error=%v", err) } else { - logger.Info("[chromium-log]", "last_100_lines", tailOutput) + t.Logf("[chromium-log] last_100_lines=%s", tailOutput) } } // restartChrome restarts Chrome via supervisorctl. -func restartChrome(t *testing.T, ctx context.Context, logger *slog.Logger) { +func restartChrome(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() - output, err := execCombinedOutput(ctx, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"}) + output, err := execCombinedOutputWithClient(ctx, c, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"}) if err != nil { - logger.Warn("[restart]", "error", err.Error(), "output", output) + t.Logf("[restart] error=%v output=%s", err, output) } else { - logger.Info("[restart]", "result", output) + t.Logf("[restart] result=%s", output) } } // takeChromePolicyScreenshot takes a screenshot of chrome://policy to debug what Chrome sees -func takeChromePolicyScreenshot(t *testing.T, ctx context.Context, logger *slog.Logger) { +func takeChromePolicyScreenshot(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() - // Use the API to take a screenshot after navigating to chrome://policy - client, err := apiClient() - if err != nil { - logger.Warn("[policy-screenshot]", "client_error", err.Error()) - return - } - // Navigate using playwright then take screenshot - cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "-e", ` + cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "-e", fmt.Sprintf(` const { chromium } = require('playwright-core'); (async () => { - const browser = await chromium.connectOverCDP('ws://127.0.0.1:9222/'); + const browser = await chromium.connectOverCDP('%s'); const contexts = browser.contexts(); const ctx = contexts[0] || await browser.newContext(); const pages = ctx.pages(); @@ -498,46 +482,43 @@ const { chromium } = require('playwright-core'); await browser.close(); })(); -`) +`, c.CDPURL())) cmd.Dir = getPlaywrightPath() out, err := cmd.CombinedOutput() if err != nil { - logger.Warn("[policy-screenshot]", "error", err.Error(), "output", string(out)) + t.Logf("[policy-screenshot] error=%v output=%s", err, string(out)) } else { - logger.Info("[policy-screenshot]", "output", string(out)) + t.Logf("[policy-screenshot] output=%s", string(out)) } - - // Ignore client since we used playwright directly - _ = client } // verifyExtensionInstalled checks if the extension was installed by Chrome. -func verifyExtensionInstalled(t *testing.T, ctx context.Context, logger *slog.Logger) { +func verifyExtensionInstalled(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() // Check the extension directory - extDir, err := execCombinedOutput(ctx, "ls", []string{"-la", "/home/kernel/extensions/"}) + extDir, err := execCombinedOutputWithClient(ctx, c, "ls", []string{"-la", "/home/kernel/extensions/"}) if err != nil { - logger.Warn("[verify]", "error", err.Error()) + t.Logf("[verify] error=%v", err) } else { - logger.Info("[verify]", "extensions_dir", extDir) + t.Logf("[verify] extensions_dir=%s", extDir) } // Check if Chrome installed the extension using Playwright to inspect chrome://extensions // Note: When loaded via --load-extension, Chrome generates a NEW extension ID based on the // directory path, which differs from the ID in update.xml (which is for the packed .crx file). // So we verify by extension name instead. - + expectedExtensionName := "Minimal Enterprise Test Extension" - logger.Info("[verify]", "expected_extension_name", expectedExtensionName) + t.Logf("[verify] expected_extension_name=%s", expectedExtensionName) // Use playwright to navigate to chrome://extensions and verify extension is loaded - logger.Info("[verify]", "action", "checking chrome://extensions via playwright") + t.Log("[verify] checking chrome://extensions via playwright") cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "-e", fmt.Sprintf(` const { chromium } = require('playwright-core'); (async () => { - const browser = await chromium.connectOverCDP('ws://127.0.0.1:9222/'); + const browser = await chromium.connectOverCDP('%s'); const contexts = browser.contexts(); const ctx = contexts[0] || await browser.newContext(); const pages = ctx.pages(); @@ -583,9 +564,52 @@ const { chromium } = require('playwright-core'); await browser.close(); })(); -`, expectedExtensionName)) +`, c.CDPURL(), expectedExtensionName)) cmd.Dir = getPlaywrightPath() out, err := cmd.CombinedOutput() - logger.Info("[playwright]", "output", string(out)) + t.Logf("[playwright] output=%s", string(out)) require.NoError(t, err, "extension verification failed: expected extension %q to be installed in chrome://extensions", expectedExtensionName) } + +// execCombinedOutputWithClient executes a command in the container via the API. +func execCombinedOutputWithClient(ctx context.Context, c *TestContainer, command string, args []string) (string, error) { + client, err := c.APIClient() + if err != nil { + return "", err + } + + req := instanceoapi.ProcessExecJSONRequestBody{ + Command: command, + Args: &args, + } + + rsp, err := client.ProcessExecWithResponse(ctx, req) + if err != nil { + return "", err + } + if rsp.JSON200 == nil { + return "", fmt.Errorf("remote exec failed: %s body=%s", rsp.Status(), string(rsp.Body)) + } + + var stdout, stderr string + if rsp.JSON200.StdoutB64 != nil && *rsp.JSON200.StdoutB64 != "" { + if b, decErr := base64.StdEncoding.DecodeString(*rsp.JSON200.StdoutB64); decErr == nil { + stdout = string(b) + } + } + if rsp.JSON200.StderrB64 != nil && *rsp.JSON200.StderrB64 != "" { + if b, decErr := base64.StdEncoding.DecodeString(*rsp.JSON200.StderrB64); decErr == nil { + stderr = string(b) + } + } + combined := stdout + stderr + + exitCode := 0 + if rsp.JSON200.ExitCode != nil { + exitCode = *rsp.JSON200.ExitCode + } + if exitCode != 0 { + return combined, &RemoteExecError{Command: command, Args: args, ExitCode: exitCode, Output: combined} + } + return combined, nil +} diff --git a/server/e2e/e2e_mv3_service_worker_test.go b/server/e2e/e2e_mv3_service_worker_test.go index 38491361..c503bd1c 100644 --- a/server/e2e/e2e_mv3_service_worker_test.go +++ b/server/e2e/e2e_mv3_service_worker_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "io" - "log/slog" "mime/multipart" "net/http" "os/exec" @@ -12,7 +11,6 @@ import ( "testing" "time" - logctx "github.com/onkernel/kernel-images/server/lib/logger" "github.com/stretchr/testify/require" ) @@ -24,54 +22,44 @@ import ( // 2. Extension appears in chrome://extensions with an active service worker // 3. Service worker responds to messages from the popup func TestMV3ServiceWorkerRegistration(t *testing.T) { + t.Parallel() ensurePlaywrightDeps(t) - image := headlessImage - name := containerName + "-mv3-sw" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) - if _, err := exec.LookPath("docker"); err != nil { require.NoError(t, err, "docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + c := NewTestContainer(t, headlessImage) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + err := c.Start(ctx, ContainerConfig{}) + require.NoError(t, err, "failed to start container") + defer c.Stop(ctx) + + t.Logf("[setup] waiting for API url=%s", c.APIBaseURL()+"/spec.yaml") + require.NoError(t, c.WaitReady(ctx), "api not ready") // Wait for DevTools to be ready - _, err = waitDevtoolsWS(ctx) + err = c.WaitDevTools(ctx) require.NoError(t, err, "devtools not ready") // Upload the MV3 test extension - logger.Info("[test]", "action", "uploading MV3 service worker test extension") - uploadMV3TestExtension(t, ctx, logger) + t.Log("[test] uploading MV3 service worker test extension") + uploadMV3TestExtension(t, ctx, c) // Run playwright script to verify service worker - logger.Info("[test]", "action", "verifying MV3 service worker via playwright") - verifyMV3ServiceWorker(t, ctx, logger) + t.Log("[test] verifying MV3 service worker via playwright") + verifyMV3ServiceWorker(t, ctx, c.CDPURL()) - logger.Info("[test]", "result", "MV3 service worker test passed") + t.Log("[test] MV3 service worker test passed") } // uploadMV3TestExtension uploads the test extension from test-extension directory. -func uploadMV3TestExtension(t *testing.T, ctx context.Context, logger *slog.Logger) { +func uploadMV3TestExtension(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // Get the path to the test extension @@ -100,23 +88,23 @@ func uploadMV3TestExtension(t *testing.T, ctx context.Context, logger *slog.Logg elapsed := time.Since(start) require.NoError(t, err, "uploadExtensionsAndRestart request error") require.Equal(t, http.StatusCreated, rsp.StatusCode(), "unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) - logger.Info("[extension]", "action", "uploaded", "elapsed", elapsed.String()) + t.Logf("[extension] uploaded elapsed=%s", elapsed.String()) } // verifyMV3ServiceWorker runs the playwright script to verify the service worker. -func verifyMV3ServiceWorker(t *testing.T, ctx context.Context, logger *slog.Logger) { +func verifyMV3ServiceWorker(t *testing.T, ctx context.Context, cdpURL string) { t.Helper() cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "index.ts", "verify-mv3-service-worker", - "--ws-url", "ws://127.0.0.1:9222/", + "--ws-url", cdpURL, "--timeout", "60000", ) cmd.Dir = getPlaywrightPath() out, err := cmd.CombinedOutput() if err != nil { - logger.Error("[playwright]", "output", string(out)) + t.Logf("[playwright] error output: %s", string(out)) } require.NoError(t, err, "MV3 service worker verification failed: %v\noutput=%s", err, string(out)) - logger.Info("[playwright]", "output", string(out)) + t.Logf("[playwright] output: %s", string(out)) } diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go index 866fbacc..02a84fad 100644 --- a/server/e2e/e2e_playwright_test.go +++ b/server/e2e/e2e_playwright_test.go @@ -3,42 +3,32 @@ package e2e import ( "context" "encoding/json" - "log/slog" "net/http" "os/exec" "testing" "time" - logctx "github.com/onkernel/kernel-images/server/lib/logger" instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/stretchr/testify/require" ) func TestPlaywrightExecuteAPI(t *testing.T) { - image := headlessImage - name := containerName + "-playwright-api" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) + t.Skipf("docker not available: %v", err) } - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 2*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err) playwrightCode := ` @@ -47,7 +37,7 @@ func TestPlaywrightExecuteAPI(t *testing.T) { return title; ` - logger.Info("[test]", "action", "executing playwright code") + t.Log("executing playwright code") req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ Code: playwrightCode, } @@ -69,7 +59,7 @@ func TestPlaywrightExecuteAPI(t *testing.T) { if rsp.JSON200.Stderr != nil { stderr = *rsp.JSON200.Stderr } - logger.Error("[test]", "error", errorMsg, "stdout", stdout, "stderr", stderr) + t.Logf("error=%s stdout=%s stderr=%s", errorMsg, stdout, stderr) } require.True(t, rsp.JSON200.Success, "expected success=true, got success=false. Error: %s", func() string { @@ -83,45 +73,37 @@ func TestPlaywrightExecuteAPI(t *testing.T) { resultBytes, err := json.Marshal(rsp.JSON200.Result) require.NoError(t, err, "failed to marshal result: %v", err) resultStr := string(resultBytes) - logger.Info("[test]", "result", resultStr) + t.Logf("result=%s", resultStr) require.Contains(t, resultStr, "Example Domain", "expected result to contain 'Example Domain'") - logger.Info("[test]", "result", "playwright execute API test passed") + t.Log("playwright execute API test passed") } // TestPlaywrightDaemonRecovery tests that the playwright daemon recovers after chromium is restarted. // The daemon maintains a warm CDP connection, but when chromium restarts, that connection breaks. // The daemon should detect the disconnection and reconnect on the next request. func TestPlaywrightDaemonRecovery(t *testing.T) { - image := headlessImage - name := containerName + "-playwright-recovery" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) + t.Parallel() if _, err := exec.LookPath("docker"); err != nil { - require.NoError(t, err, "docker not available: %v", err) + t.Skipf("docker not available: %v", err) } - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - require.NoError(t, waitHTTPOrExitWithLogs(ctx, apiBaseURL+"/spec.yaml", exitCh, name), "api not ready: %v", err) + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err) // Helper to execute playwright code and verify success executeAndVerify := func(description string) { - logger.Info("[test]", "action", description) + t.Logf("action: %s", description) code := `return await page.evaluate(() => navigator.userAgent);` req := instanceoapi.ExecutePlaywrightCodeJSONRequestBody{Code: code} @@ -143,14 +125,14 @@ func TestPlaywrightDaemonRecovery(t *testing.T) { } require.NotNil(t, rsp.JSON200.Result, "%s: expected result to be non-nil", description) - logger.Info("[test]", "result", "success", "description", description) + t.Logf("%s: success", description) } // Step 1: Execute playwright code to start the daemon and establish CDP connection executeAndVerify("initial execution (starts daemon)") // Step 2: Restart chromium via supervisorctl - logger.Info("[test]", "action", "restarting chromium via supervisorctl") + t.Log("restarting chromium via supervisorctl") { args := []string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"} req := instanceoapi.ProcessExecJSONRequestBody{ @@ -162,19 +144,19 @@ func TestPlaywrightDaemonRecovery(t *testing.T) { require.Equal(t, http.StatusOK, rsp.StatusCode(), "supervisorctl restart unexpected status: %s body=%s", rsp.Status(), string(rsp.Body)) if rsp.JSON200.StdoutB64 != nil { - logger.Info("[test]", "supervisorctl_stdout_b64", *rsp.JSON200.StdoutB64) + t.Logf("supervisorctl stdout_b64: %s", *rsp.JSON200.StdoutB64) } if rsp.JSON200.StderrB64 != nil { - logger.Info("[test]", "supervisorctl_stderr_b64", *rsp.JSON200.StderrB64) + t.Logf("supervisorctl stderr_b64: %s", *rsp.JSON200.StderrB64) } } // Step 3: Wait for chromium to be ready again - logger.Info("[test]", "action", "waiting for chromium to be ready after restart") + t.Log("waiting for chromium to be ready after restart") time.Sleep(2 * time.Second) // Step 4: Execute playwright code again - daemon should recover executeAndVerify("execution after chromium restart (daemon should recover)") - logger.Info("[test]", "result", "playwright daemon recovery test passed") + t.Log("playwright daemon recovery test passed") } diff --git a/server/e2e/e2e_webrequest_extension_test.go b/server/e2e/e2e_webrequest_extension_test.go index 916e2a91..eeda758f 100644 --- a/server/e2e/e2e_webrequest_extension_test.go +++ b/server/e2e/e2e_webrequest_extension_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "io" - "log/slog" "mime/multipart" "net/http" "os/exec" @@ -12,7 +11,6 @@ import ( "testing" "time" - logctx "github.com/onkernel/kernel-images/server/lib/logger" "github.com/stretchr/testify/require" ) @@ -28,57 +26,46 @@ import ( // Previously, this required update.xml and .crx files for ExtensionInstallForcelist. // The fix allows falling back to --load-extension for unpacked extensions. func TestWebRequestExtensionFallback(t *testing.T) { + t.Parallel() ensurePlaywrightDeps(t) - image := headlessImage - name := containerName + "-webrequest-ext" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) - if _, err := exec.LookPath("docker"); err != nil { require.NoError(t, err, "docker not available: %v", err) } - // Clean slate - _ = stopContainer(baseCtx, name) - - env := map[string]string{} - - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container: %v", err) - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + // Create and start container with dynamic ports + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{}), "failed to start container") + defer c.Stop(ctx) + + t.Logf("[setup] waiting for API: %s/spec.yaml", c.APIBaseURL()) + require.NoError(t, c.WaitReady(ctx), "api not ready") // Wait for DevTools to be ready - _, err = waitDevtoolsWS(ctx) - require.NoError(t, err, "devtools not ready") + require.NoError(t, c.WaitDevTools(ctx), "devtools not ready") // Upload the webRequest test extension (no update.xml or .crx) - logger.Info("[test]", "action", "uploading webRequest test extension (without update.xml/.crx)") - uploadWebRequestTestExtension(t, ctx, logger) + t.Log("[test] uploading webRequest test extension (without update.xml/.crx)") + uploadWebRequestTestExtension(t, ctx, c) // The upload success (201) is the main assertion - that proves the fallback worked. // Additional verification that extension actually loaded in browser is nice-to-have. - logger.Info("[test]", "action", "verifying webRequest extension appears in chrome://extensions") - verifyWebRequestExtension(t, ctx, logger) + t.Log("[test] verifying webRequest extension appears in chrome://extensions") + verifyWebRequestExtension(t, ctx, c.CDPURL()) - logger.Info("[test]", "result", "webRequest extension fallback test passed") + t.Log("[test] webRequest extension fallback test passed") } // uploadWebRequestTestExtension uploads the test extension with webRequest permission. // This extension does NOT have update.xml or .crx files, so it should use the // --load-extension fallback path. -func uploadWebRequestTestExtension(t *testing.T, ctx context.Context, logger *slog.Logger) { +func uploadWebRequestTestExtension(t *testing.T, ctx context.Context, c *TestContainer) { t.Helper() - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // Get the path to the test extension @@ -115,19 +102,19 @@ func uploadWebRequestTestExtension(t *testing.T, ctx context.Context, logger *sl "This likely means the --load-extension fallback is not working for webRequest extensions.", rsp.StatusCode(), string(rsp.Body)) - logger.Info("[extension]", "action", "uploaded", "elapsed", elapsed.String()) + t.Logf("[extension] uploaded in %s", elapsed) } // verifyWebRequestExtension verifies the extension is loaded by checking chrome://extensions title. // This is a lightweight check - the main test assertion is that upload returned 201. -func verifyWebRequestExtension(t *testing.T, ctx context.Context, logger *slog.Logger) { +func verifyWebRequestExtension(t *testing.T, ctx context.Context, cdpURL string) { t.Helper() // Use verify-title-contains to confirm we can navigate to chrome://extensions // This proves chromium restarted successfully with the extension cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "index.ts", "verify-title-contains", - "--ws-url", "ws://127.0.0.1:9222/", + "--ws-url", cdpURL, "--url", "chrome://extensions", "--substr", "Extensions", "--timeout", "30000", @@ -135,10 +122,9 @@ func verifyWebRequestExtension(t *testing.T, ctx context.Context, logger *slog.L cmd.Dir = getPlaywrightPath() out, err := cmd.CombinedOutput() if err != nil { - logger.Warn("[playwright]", "output", string(out), "error", err) // Log but don't fail - the key assertion is the 201 response from upload - t.Logf("Warning: chrome://extensions verification failed (non-critical): %v", err) + t.Logf("Warning: chrome://extensions verification failed (non-critical): %v\nOutput: %s", err, string(out)) } else { - logger.Info("[playwright]", "result", "chrome://extensions accessible after extension upload") + t.Log("[playwright] chrome://extensions accessible after extension upload") } } diff --git a/server/e2e/e2e_zip_transfer_bench_test.go b/server/e2e/e2e_zip_transfer_bench_test.go index 67e2a260..10e53e2c 100644 --- a/server/e2e/e2e_zip_transfer_bench_test.go +++ b/server/e2e/e2e_zip_transfer_bench_test.go @@ -6,14 +6,12 @@ import ( "encoding/base64" "fmt" "io" - "log/slog" "mime/multipart" "net/http" "os/exec" "testing" "time" - logctx "github.com/onkernel/kernel-images/server/lib/logger" instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/stretchr/testify/require" ) @@ -24,36 +22,29 @@ import ( // // Run with: go test -v -run TestZipTransferTiming ./e2e/... func TestZipTransferTiming(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("docker"); err != nil { t.Skip("docker not available") } - image := headlessImage - name := containerName + "-zip-transfer" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) - - // Clean slate - _ = stopContainer(baseCtx, name) - env := map[string]string{ "WIDTH": "1024", "HEIGHT": "768", } - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container") - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - t.Logf("Waiting for API...") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + // Start container with dynamic ports + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{Env: env}), "failed to start container") + defer c.Stop(ctx) - client, err := apiClient() + t.Log("Waiting for API...") + require.NoError(t, c.WaitReady(ctx), "api not ready") + + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // First, let's populate user-data with some content by navigating to a page @@ -264,36 +255,29 @@ func avgInt64(vals []int64) int64 { // // Run with: go test -v -run TestZstdTransferTiming ./e2e/... func TestZstdTransferTiming(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("docker"); err != nil { t.Skip("docker not available") } - image := headlessImage - name := containerName + "-zstd-transfer" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) - - // Clean slate - _ = stopContainer(baseCtx, name) - env := map[string]string{ "WIDTH": "1024", "HEIGHT": "768", } - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container") - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - t.Logf("Waiting for API...") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + // Start container with dynamic ports + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{Env: env}), "failed to start container") + defer c.Stop(ctx) + + t.Log("Waiting for API...") + require.NoError(t, c.WaitReady(ctx), "api not ready") - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // Populate user-data with some content @@ -423,36 +407,29 @@ func uploadZstd(ctx context.Context, client *instanceoapi.ClientWithResponses, a // // Run with: go test -v -run TestZipVsZstdComparison ./e2e/... func TestZipVsZstdComparison(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("docker"); err != nil { t.Skip("docker not available") } - image := headlessImage - name := containerName + "-comparison" - - logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelInfo})) - baseCtx := logctx.AddToContext(context.Background(), logger) - - // Clean slate - _ = stopContainer(baseCtx, name) - env := map[string]string{ "WIDTH": "1024", "HEIGHT": "768", } - // Start container - _, exitCh, err := runContainer(baseCtx, image, name, env) - require.NoError(t, err, "failed to start container") - defer stopContainer(baseCtx, name) - - ctx, cancel := context.WithTimeout(baseCtx, 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - t.Logf("Waiting for API...") - require.NoError(t, waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh), "api not ready") + // Start container with dynamic ports + c := NewTestContainer(t, headlessImage) + require.NoError(t, c.Start(ctx, ContainerConfig{Env: env}), "failed to start container") + defer c.Stop(ctx) + + t.Log("Waiting for API...") + require.NoError(t, c.WaitReady(ctx), "api not ready") - client, err := apiClient() + client, err := c.APIClient() require.NoError(t, err, "failed to create API client") // Populate user-data diff --git a/server/go.mod b/server/go.mod index a9ce8ec7..f0270958 100644 --- a/server/go.mod +++ b/server/go.mod @@ -6,6 +6,8 @@ require ( github.com/avast/retry-go/v5 v5.0.0 github.com/coder/websocket v1.8.14 github.com/creack/pty v1.1.24 + github.com/docker/docker v28.5.1+incompatible + github.com/docker/go-connections v0.6.0 github.com/fsnotify/fsnotify v1.9.0 github.com/getkin/kin-openapi v0.132.0 github.com/ghodss/yaml v1.0.0 @@ -13,37 +15,83 @@ require ( github.com/go-chi/chi/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/kelseyhightower/envconfig v1.4.0 + github.com/klauspost/compress v1.18.3 github.com/m1k1o/neko/server v0.0.0-20251008185748-46e2fc7d3866 github.com/nrednav/cuid2 v1.1.0 github.com/oapi-codegen/runtime v1.1.2 github.com/samber/lo v1.52.0 github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 golang.org/x/sync v0.17.0 - golang.org/x/sys v0.38.0 + golang.org/x/sys v0.39.0 golang.org/x/term v0.37.0 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.18.3 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/text v0.27.0 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/gorm v1.25.7 // indirect diff --git a/server/go.sum b/server/go.sum index 324dfabf..559fc057 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,18 +1,53 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k= github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= @@ -25,16 +60,28 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -50,12 +97,34 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/nrednav/cuid2 v1.1.0 h1:Y2P9Fo1Iz7lKuwcn+fS0mbxkNvEqoNLUtm0+moHCnYc= github.com/nrednav/cuid2 v1.1.0/go.mod h1:jBjkJAI+QLM4EUGvtwGDHC1cP1QQrRNfLo/A7qJFDhA= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= @@ -66,45 +135,108 @@ github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletI github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866 h1:Cix/sgZLCsavpiTFxDLPbUOXob50IekCg5mgh+i4D4Q= github.com/onkernel/neko/server v0.0.0-20251008185748-46e2fc7d3866/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=