diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index f65ae214..2627425a 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -62,3 +62,56 @@ jobs: env: E2E_CHROMIUM_HEADFUL_IMAGE: onkernel/chromium-headful:${{ steps.vars.outputs.short_sha }} E2E_CHROMIUM_HEADLESS_IMAGE: onkernel/chromium-headless:${{ steps.vars.outputs.short_sha }} + + # Runs the same e2e suite against the Hypeman backend instead of local Docker. + # We do NOT build the images in Hypeman (its builder VM is RAM-disk-capped at + # memory_mb=16384, which is too small for the chromium image build — see PR + # description). Instead we reuse the public images that build-headful/ + # build-headless just pushed to Docker Hub: Hypeman pulls them itself on + # instance create (any public/private registry works via the host's docker + # creds), so the runner needs no docker login. KI_E2E_BACKEND=hypeman selects + # the remote-VM backend; it reaches instances through the host's wildcard + # ingress derived from HYPEMAN_BASE_URL. + test-hypeman: + runs-on: ubuntu-latest + needs: [build-headful, build-headless] + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Chrome + uses: browser-actions/setup-chrome@v2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "server/go.mod" + cache: true + + - name: Compute short SHA for images + id: vars + shell: bash + run: echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + - name: Run server Makefile tests against Hypeman + run: make test + working-directory: server + env: + KI_E2E_BACKEND: hypeman + HYPEMAN_BASE_URL: ${{ vars.HYPEMAN_API_URL }} + HYPEMAN_API_KEY: ${{ secrets.HYPEMAN_API_KEY }} + E2E_CHROMIUM_HEADFUL_IMAGE: onkernel/chromium-headful:${{ steps.vars.outputs.short_sha }} + E2E_CHROMIUM_HEADLESS_IMAGE: onkernel/chromium-headless:${{ steps.vars.outputs.short_sha }} diff --git a/server/e2e/backend.go b/server/e2e/backend.go new file mode 100644 index 00000000..2c554912 --- /dev/null +++ b/server/e2e/backend.go @@ -0,0 +1,119 @@ +package e2e + +import ( + "context" + "os" + "strings" + "testing" + + instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" +) + +// ContainerConfig holds optional configuration for instance startup. +// +// It is shared by every backend so that the ~24 e2e_*_test.go files can keep +// calling Start with the same shape regardless of where the browser instance +// actually runs (a local Docker container or a remote Hypeman VM). +type ContainerConfig struct { + Env map[string]string + // HostAccess requests that the browser instance be able to reach a service + // the test stands up on its own host (loopback) — used by tests with a local + // fixture server (capmonster, persisted-login). How it's provided is a + // backend detail (the Docker backend maps host.docker.internal); backends + // that cannot bridge a remote instance to the test host reject it. + HostAccess bool +} + +// Backend is the abstraction every e2e browser-instance provider implements. +// +// It captures the public surface that the test files consume via *TestContainer. +// Two implementations exist: +// +// - dockerBackend: runs the image as a local Docker container via +// testcontainers-go (the historical behavior, still the default). +// - hypemanBackend: starts the image as a remote VM on a running Hypeman dev +// server using the github.com/kernel/hypeman-go client library. +// +// Keeping the surface identical means selecting a backend is a pure factory +// concern and requires no changes in individual tests. +type Backend interface { + // Start provisions and boots the browser instance. + Start(ctx context.Context, cfg ContainerConfig) error + // Stop tears the instance down and releases its resources. + Stop(ctx context.Context) error + + // APIBaseURL returns the base URL for the instance's control-plane API + // server (container port 10001). + APIBaseURL() string + // CDPURL returns the WebSocket URL for the DevTools proxy (port 9222). + CDPURL() string + // CDPAddr returns the TCP host:port for the DevTools proxy (port 9222). + CDPAddr() string + // ChromeDriverURL returns the base HTTP URL for the ChromeDriver proxy + // (port 9224). + ChromeDriverURL() string + + // APIClient returns an OpenAPI client bound to APIBaseURL. + APIClient() (*instanceoapi.ClientWithResponses, error) + // APIClientNoKeepAlive returns an OpenAPI client that disables HTTP + // connection reuse (useful after server restarts). + APIClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error) + + // WaitReady blocks until the instance's API server is serving. + WaitReady(ctx context.Context) error + // WaitDevTools blocks until the CDP endpoint accepts connections. + WaitDevTools(ctx context.Context) error + // WaitChromeDriver blocks until the ChromeDriver proxy reports ready. + WaitChromeDriver(ctx context.Context) error + + // Exec runs a command inside the instance and returns the exit code and + // combined stdout+stderr output. + Exec(ctx context.Context, cmd []string) (int, string, error) + + // ExitCh returns a channel that fires when the instance exits. + ExitCh() <-chan error +} + +// BackendKind enumerates the supported e2e backends. +type BackendKind string + +const ( + BackendDocker BackendKind = "docker" + BackendHypeman BackendKind = "hypeman" +) + +// envBackendKind is the env var that selects the backend. It defaults to +// "docker" so existing CI (which sets nothing) is unchanged. +const envBackendKind = "KI_E2E_BACKEND" + +// backendKindFromEnv reads and normalizes KI_E2E_BACKEND, defaulting to docker. +func backendKindFromEnv() BackendKind { + v := strings.TrimSpace(strings.ToLower(os.Getenv(envBackendKind))) + if v == "" { + return BackendDocker + } + return BackendKind(v) +} + +// newBackend constructs the backend selected by the KI_E2E_BACKEND env var. +// +// Selection is resolved here (and not per test) so that adding a backend never +// requires touching the test files. Unknown values fail the test loudly rather +// than silently falling back, to avoid masking misconfiguration in CI. +func newBackend(tb testing.TB, image string) Backend { + tb.Helper() + kind := backendKindFromEnv() + switch kind { + case BackendDocker: + return newDockerBackend(image) + case BackendHypeman: + b, err := newHypemanBackend(image, hypemanConfigFromEnv()) + if err != nil { + tb.Fatalf("e2e: failed to configure hypeman backend: %v", err) + } + return b + default: + tb.Fatalf("e2e: unsupported %s=%q (want %q or %q)", envBackendKind, kind, BackendDocker, BackendHypeman) + return nil + } +} diff --git a/server/e2e/backend_docker.go b/server/e2e/backend_docker.go new file mode 100644 index 00000000..7c583923 --- /dev/null +++ b/server/e2e/backend_docker.go @@ -0,0 +1,239 @@ +package e2e + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// dockerBackend runs the image as a local Docker container via testcontainers-go. +// +// This is the historical e2e behavior, preserved verbatim and moved behind the +// Backend interface. It enables parallel test execution by giving each test its +// own dynamically allocated host ports. +type dockerBackend struct { + Name string + Image string + APIPort int // dynamically allocated host port -> container 10001 + CDPPort int // dynamically allocated host port -> container 9222 + ChromeDriverPort int // dynamically allocated host port -> container 9224 + ctr testcontainers.Container +} + +// newDockerBackend returns a Docker-backed Backend for the given image. +func newDockerBackend(image string) Backend { + return &dockerBackend{Image: image} +} + +// Start starts the container with the given configuration using testcontainers-go. +func (c *dockerBackend) 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", "9224/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() + + chromeDriverPort, err := ctr.MappedPort(ctx, "9224/tcp") + if err != nil { + return fmt.Errorf("failed to get ChromeDriver port: %w", err) + } + c.ChromeDriverPort = chromeDriverPort.Int() + + return nil +} + +// Stop stops and removes the container. +func (c *dockerBackend) 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 *dockerBackend) 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 *dockerBackend) CDPURL() string { + return fmt.Sprintf("ws://127.0.0.1:%d/", c.CDPPort) +} + +// CDPAddr returns the TCP address for the container's DevTools proxy. +func (c *dockerBackend) CDPAddr() string { + return fmt.Sprintf("127.0.0.1:%d", c.CDPPort) +} + +// ChromeDriverURL returns the base HTTP URL for the container's ChromeDriver proxy. +func (c *dockerBackend) ChromeDriverURL() string { + return fmt.Sprintf("http://127.0.0.1:%d", c.ChromeDriverPort) +} + +// APIClient creates an OpenAPI client for this container's API. +func (c *dockerBackend) APIClient() (*instanceoapi.ClientWithResponses, error) { + return instanceoapi.NewClientWithResponses(c.APIBaseURL()) +} + +// APIClientNoKeepAlive creates an API client that doesn't reuse connections. +func (c *dockerBackend) APIClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error) { + transport := &http.Transport{ + DisableKeepAlives: true, + } + httpClient := &http.Client{Transport: transport} + return instanceoapi.NewClientWithResponses(c.APIBaseURL(), instanceoapi.WithHTTPClient(httpClient)) +} + +// 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 performs an additional health check. +func (c *dockerBackend) 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 + } + } + } + } +} + +// WaitDevTools waits for the CDP WebSocket endpoint to be ready. +func (c *dockerBackend) WaitDevTools(ctx context.Context) error { + return wait.ForListeningPort(nat.Port("9222/tcp")). + WithStartupTimeout(2*time.Minute). + WaitUntilReady(ctx, c.ctr) +} + +// WaitChromeDriver waits for the ChromeDriver proxy (and upstream ChromeDriver) +// to be ready by polling the /status endpoint. +func (c *dockerBackend) WaitChromeDriver(ctx context.Context) error { + statusURL := c.ChromeDriverURL() + "/status" + client := &http.Client{Timeout: 2 * time.Second} + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + resp, err := client.Get(statusURL) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + +// Exec executes a command inside the container and returns the combined output. +func (c *dockerBackend) 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 +} + +// ExitCh returns a channel that receives when the container exits. +// Note: testcontainers-go handles this internally; this is kept for API +// compatibility and returns a channel that never fires. +func (c *dockerBackend) ExitCh() <-chan error { + ch := make(chan error, 1) + return ch +} diff --git a/server/e2e/backend_hypeman.go b/server/e2e/backend_hypeman.go new file mode 100644 index 00000000..884a345c --- /dev/null +++ b/server/e2e/backend_hypeman.go @@ -0,0 +1,608 @@ +package e2e + +import ( + "context" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + hypeman "github.com/kernel/hypeman-go" + "github.com/kernel/hypeman-go/option" + instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/nrednav/cuid2" +) + +// Guest ports exposed by the kernel-images browser image. They are fixed inside +// the guest; the Docker backend remaps them to random host ports, while the +// Hypeman backend reaches them either through an ingress (by listen port) or +// directly on the instance's private network IP. +const ( + hypemanAPIPort = 10001 + hypemanCDPPort = 9222 + hypemanChromeDriverPort = 9224 +) + +// Env var names for configuring the Hypeman backend. Secrets are referenced by +// name only and never hardcoded. +const ( + // envHypemanBaseURL overrides the hypeman dev server URL. If unset, the SDK + // falls back to its own HYPEMAN_BASE_URL lookup. + envHypemanBaseURL = "KI_E2E_HYPEMAN_BASE_URL" + // envHypemanToken is the preferred auth token var. It matches the API + // service's Railway staging variable name (HYPEMAN_AUTH_TOKEN). The SDK's + // native HYPEMAN_API_KEY is also honored as a fallback. + envHypemanToken = "HYPEMAN_AUTH_TOKEN" + // envHypemanGPUDevices is an optional comma-separated list of device IDs or + // names to attach for GPU/PCI passthrough. + envHypemanGPUDevices = "KI_E2E_HYPEMAN_GPU_DEVICES" + // envHypemanGPUProfile requests a vGPU profile (e.g. "NVIDIA L40S-2Q") for + // the instance; the host assigns the backing mdev. Required to boot the vGPU + // browser image (chromium-headful-vgpu). + envHypemanGPUProfile = "KI_E2E_HYPEMAN_GPU_PROFILE" + // envHypemanDiskIOBps overrides the instance disk I/O rate limit. Defaults to + // defaultHypemanDiskIOBps; the hypeman default for ad-hoc instances is much + // lower (~15MB/s), which starves cold first-reads at boot (e.g. the in-guest + // playwright daemon's ~43MB of node_modules) and can blow its 5s start budget. + envHypemanDiskIOBps = "KI_E2E_HYPEMAN_DISK_IO_BPS" + // envHypemanInstanceSize optionally overrides the VM memory size. + envHypemanInstanceSize = "KI_E2E_HYPEMAN_SIZE" + // envHypemanIngressDomain overrides the wildcard ingress base domain. If + // unset it is derived from the base URL host by stripping a leading + // "hypeman." prefix (e.g. hypeman.dev-yul-hypeman-1.kernel.sh -> + // dev-yul-hypeman-1.kernel.sh), matching the host's "{instance}." + // wildcard ingresses. + envHypemanIngressDomain = "KI_E2E_HYPEMAN_INGRESS_DOMAIN" + // envHypemanIngressTLS toggles TLS on ingress endpoints. Defaults to true + // (the host terminates TLS with a wildcard cert); set 0/false for plaintext. + envHypemanIngressTLS = "KI_E2E_HYPEMAN_INGRESS_TLS" + // envHypemanRawIP forces reaching the instance on its private network IP + // instead of via ingress. Only works from a network with L3 reachability to + // the hypeman instance subnet (e.g. the API's own tailnet-tagged hosts). + envHypemanRawIP = "KI_E2E_HYPEMAN_RAW_IP" +) + +// defaultHypemanDiskIOBps matches what production browser instances run at, so +// e2e instances aren't disk-throttled into spurious timeouts. Format is the +// hypeman human-readable rate (e.g. "62MB/s"); "MiB" is not accepted. +const defaultHypemanDiskIOBps = "62MB/s" + +// ingressRole maps a logical endpoint to the ingress listen port and the guest +// target port. Hostname routing uses a single wildcard hostname +// "{instance}." and differentiates roles by listen port, matching the +// host's existing convention (the browser API is exposed on :444 -> guest +// :10001). cdp/cd reuse the guest port as the listen port. +type ingressRole struct { + role string + listenPort int64 + targetPort int64 +} + +var ingressRoles = []ingressRole{ + {role: "api", listenPort: 444, targetPort: hypemanAPIPort}, + {role: "cdp", listenPort: hypemanCDPPort, targetPort: hypemanCDPPort}, + {role: "cd", listenPort: hypemanChromeDriverPort, targetPort: hypemanChromeDriverPort}, +} + +// Tag applied to ingresses this backend creates, so they are recognizable as +// e2e-managed. We still reuse any pre-existing ingress (e.g. the API's own +// browser ingress) regardless of tag — matching is by rule shape. +const ( + ingressTagKey = "managed-by" + ingressTagVal = "ki-e2e" +) + +// hypemanBackend starts the image as a remote VM on a running Hypeman dev server +// using the github.com/kernel/hypeman-go client library. +// +// Endpoints are reached one of two ways: +// +// - Ingress (default): a wildcard ingress per role routes +// ".:" through the host's reverse proxy to the +// instance's guest port. Each rule uses the "{instance}" hostname capture so +// a single host-level ingress serves every instance; rules are found-or- +// created (reusing pre-existing ones, e.g. the browser API :444 -> :10001). +// Works from anywhere that can resolve and reach the host. +// - Raw network IP (opt-in via KI_E2E_HYPEMAN_RAW_IP): the instance's private +// IP on the fixed guest ports. Needs L3 reachability to the instance subnet. +// +// Command execution runs against the instance's own API server (/process/exec) +// so callers get the same (exitCode, combinedOutput, error) shape as Docker. +type hypemanBackend struct { + client hypeman.Client + image string + cfg hypemanConfig + + instanceID string + name string + ip string + + // Derived from cfg at construction (see newHypemanBackend). + useIngress bool + ingressDomain string + ingressTLS bool + + exitCh chan error +} + +// hypemanConfig holds every option for the hypeman backend. Callers populate it +// explicitly; the backend itself reads no environment variables. The e2e factory +// builds it once via hypemanConfigFromEnv, but other callers can construct it +// directly (e.g. a future programmatic harness) and decide how to source values. +type hypemanConfig struct { + // BaseURL and Token authenticate against the hypeman control API. Both are + // required (validated by newHypemanBackend). + BaseURL string + Token string + // IngressDomain is the wildcard ingress base domain. If empty (and not + // RawIP), it is derived from BaseURL by stripping a leading "hypeman." label. + IngressDomain string + // IngressTLS serves ingress endpoints over TLS (https/wss on :443/role port). + IngressTLS bool + // RawIP reaches the instance on its private network IP instead of via ingress + // (needs L3 reachability to the instance subnet). + RawIP bool + // Size overrides the VM memory size; DiskIOBps overrides the disk I/O rate + // limit (hypeman "62MB/s"-style format). Empty DiskIOBps => defaultHypemanDiskIOBps. + Size string + DiskIOBps string + // GPUDevices attaches PCI-passthrough devices; GPUProfile requests a vGPU + // profile (e.g. "NVIDIA L40S-2Q"), required to boot the vGPU browser image. + GPUDevices []string + GPUProfile string +} + +// hypemanConfigFromEnv resolves a hypemanConfig from the KI_E2E_HYPEMAN_* / +// HYPEMAN_* environment variables. This is the single place the hypeman backend's +// configuration is read from the environment; the backend and Start do not. +func hypemanConfigFromEnv() hypemanConfig { + return hypemanConfig{ + BaseURL: firstNonEmpty(os.Getenv(envHypemanBaseURL), os.Getenv("HYPEMAN_BASE_URL")), + Token: firstNonEmpty(os.Getenv(envHypemanToken), os.Getenv("HYPEMAN_API_KEY")), + IngressDomain: strings.TrimSpace(os.Getenv(envHypemanIngressDomain)), + IngressTLS: envBoolDefault(envHypemanIngressTLS, true), + RawIP: isTruthy(os.Getenv(envHypemanRawIP)), + Size: strings.TrimSpace(os.Getenv(envHypemanInstanceSize)), + DiskIOBps: strings.TrimSpace(os.Getenv(envHypemanDiskIOBps)), + GPUDevices: parseCommaList(os.Getenv(envHypemanGPUDevices)), + GPUProfile: strings.TrimSpace(os.Getenv(envHypemanGPUProfile)), + } +} + +// newHypemanBackend validates the config and constructs a hypeman-backed Backend. +// It reads no environment — all options come from cfg. +func newHypemanBackend(image string, cfg hypemanConfig) (Backend, error) { + if cfg.BaseURL == "" || cfg.Token == "" { + return nil, fmt.Errorf( + "hypeman backend requires a base URL (%s or HYPEMAN_BASE_URL) and a token (%s or HYPEMAN_API_KEY)", + envHypemanBaseURL, envHypemanToken, + ) + } + + domain := cfg.IngressDomain + if domain == "" { + domain = deriveIngressDomain(cfg.BaseURL) + } + + return &hypemanBackend{ + client: hypeman.NewClient(option.WithBaseURL(cfg.BaseURL), option.WithAPIKey(cfg.Token)), + image: image, + cfg: cfg, + useIngress: !cfg.RawIP && domain != "", + ingressDomain: domain, + ingressTLS: cfg.IngressTLS, + exitCh: make(chan error, 1), + }, nil +} + +// deriveIngressDomain extracts the wildcard ingress base domain from the control +// API base URL by stripping a leading "hypeman." label. +func deriveIngressDomain(baseURL string) string { + u, err := url.Parse(baseURL) + if err != nil || u.Hostname() == "" { + return "" + } + return strings.TrimPrefix(u.Hostname(), "hypeman.") +} + +// Start creates and boots a hypeman instance for the image, waits for it to +// reach the Running state, then prepares the chosen routing mode. +func (c *hypemanBackend) Start(ctx context.Context, cfg ContainerConfig) error { + if cfg.HostAccess { + // A remote VM has no equivalent of Docker's host.docker.internal; we + // reject rather than silently ignore so host-fixture tests (capmonster, + // persisted-login) fail loudly here and stay on the Docker backend. + return fmt.Errorf("hypeman backend does not support ContainerConfig.HostAccess (no host loopback bridge for remote instances); run host-access tests on the docker backend") + } + + env := make(map[string]string, len(cfg.Env)+1) + for k, v := range cfg.Env { + env[k] = v + } + // Mirror the Docker backend: ensure --no-sandbox is present for CI. + if flags, ok := env["CHROMIUM_FLAGS"]; !ok || flags == "" { + env["CHROMIUM_FLAGS"] = "--no-sandbox" + } else { + env["CHROMIUM_FLAGS"] = flags + " --no-sandbox" + } + + c.name = hypemanInstanceName() + params := hypeman.InstanceNewParams{ + Image: c.image, + Name: c.name, + Env: env, + } + if c.cfg.Size != "" { + params.Size = hypeman.String(c.cfg.Size) + } + if len(c.cfg.GPUDevices) > 0 { + params.Devices = c.cfg.GPUDevices + } + if c.cfg.GPUProfile != "" { + params.GPU = hypeman.InstanceNewParamsGPU{Profile: hypeman.String(c.cfg.GPUProfile)} + } + diskIO := c.cfg.DiskIOBps + if diskIO == "" { + diskIO = defaultHypemanDiskIOBps + } + params.DiskIoBps = hypeman.String(diskIO) + + inst, err := c.client.Instances.New(ctx, params) + if err != nil { + return fmt.Errorf("hypeman: create instance: %w", err) + } + c.instanceID = inst.ID + + // Wait for the guest program to start. The SDK caps the server-side wait at + // a few minutes; loop until our context deadline if needed. + if err := c.waitForRunning(ctx); err != nil { + return err + } + + if c.useIngress { + // Ensure the wildcard ingress rules exist; endpoints derive from the + // instance name + domain, so no instance IP is needed. + return c.ensureIngress(ctx) + } + + // Raw-IP fallback: reach the instance directly on its private network IP. + ip, err := c.resolveIP(ctx) + if err != nil { + return err + } + c.ip = ip + return nil +} + +// ensureIngress finds or creates a wildcard ingress for each role. Ingresses are +// host-level constructs keyed by rule shape (wildcard hostname + listen port -> +// target port), so we reuse any pre-existing rule (e.g. the API's browser +// ingress) and only create what's missing — never one ingress per instance. +func (c *hypemanBackend) ensureIngress(ctx context.Context) error { + have := c.existingRuleSet(ctx) + for _, r := range ingressRoles { + key := ruleKey(c.wildcardHost(), r.listenPort, r.targetPort) + if have[key] { + continue + } + if _, err := c.client.Ingresses.New(ctx, c.roleIngressParams(r)); err != nil { + // Another runner may have created it concurrently; re-check. + if c.existingRuleSet(ctx)[key] { + continue + } + return fmt.Errorf("hypeman: ensure ingress for role %q (:%d->:%d): %w", r.role, r.listenPort, r.targetPort, err) + } + } + return nil +} + +// existingRuleSet lists all ingresses and indexes their rules by shape so we can +// reuse any rule (regardless of ingress name/tag) that already provides routing. +func (c *hypemanBackend) existingRuleSet(ctx context.Context) map[string]bool { + set := map[string]bool{} + list, err := c.client.Ingresses.List(ctx, hypeman.IngressListParams{}) + if err != nil || list == nil { + return set + } + for _, ing := range *list { + for _, rule := range ing.Rules { + set[ruleKey(rule.Match.Hostname, rule.Match.Port, rule.Target.Port)] = true + } + } + return set +} + +func (c *hypemanBackend) roleIngressParams(r ingressRole) hypeman.IngressNewParams { + return hypeman.IngressNewParams{ + Name: "ki-e2e-" + r.role, + Rules: []hypeman.IngressRuleParam{{ + Match: hypeman.IngressMatchParam{ + Hostname: c.wildcardHost(), + Port: hypeman.Int(r.listenPort), + }, + Target: hypeman.IngressTargetParam{ + Instance: "{instance}", + Port: r.targetPort, + }, + Tls: hypeman.Bool(c.ingressTLS), + }}, + Tags: map[string]string{ingressTagKey: ingressTagVal}, + } +} + +func ruleKey(host string, listen, target int64) string { + return fmt.Sprintf("%s|%d|%d", host, listen, target) +} + +// wildcardHost is the pattern hostname ("{instance}.") used in ingress +// rules; ingressHost is the concrete hostname for this instance. +func (c *hypemanBackend) wildcardHost() string { return "{instance}." + c.ingressDomain } +func (c *hypemanBackend) ingressHost() string { return c.name + "." + c.ingressDomain } + +func (c *hypemanBackend) listenPortFor(role string) int64 { + for _, r := range ingressRoles { + if r.role == role { + return r.listenPort + } + } + return 0 +} + +// waitForRunning polls the instance wait endpoint until the instance is Running +// or the context is done. +func (c *hypemanBackend) waitForRunning(ctx context.Context) error { + for { + if err := ctx.Err(); err != nil { + return fmt.Errorf("hypeman: waiting for Running: %w", err) + } + resp, err := c.client.Instances.Wait(ctx, c.instanceID, hypeman.InstanceWaitParams{ + State: hypeman.InstanceWaitParamsStateRunning, + Timeout: hypeman.String("60s"), + }) + if err == nil && resp != nil && string(resp.State) == string(hypeman.InstanceStateRunning) { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("hypeman: timed out waiting for instance %s to reach Running", c.instanceID) + case <-time.After(time.Second): + } + } +} + +// resolveIP fetches the instance until a network IP is assigned. +func (c *hypemanBackend) resolveIP(ctx context.Context) (string, error) { + for { + inst, err := c.client.Instances.Get(ctx, c.instanceID) + if err == nil && inst != nil && strings.TrimSpace(inst.Network.IP) != "" { + return inst.Network.IP, nil + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("hypeman: timed out resolving IP for instance %s", c.instanceID) + case <-time.After(time.Second): + } + } +} + +// Stop deletes the hypeman instance. The shared wildcard ingresses are +// host-level and intentionally left in place for reuse by other instances/runs. +func (c *hypemanBackend) Stop(ctx context.Context) error { + if c.instanceID == "" { + return nil + } + if err := c.client.Instances.Delete(ctx, c.instanceID); err != nil { + return fmt.Errorf("hypeman: delete instance %s: %w", c.instanceID, err) + } + select { + case c.exitCh <- nil: + default: + } + return nil +} + +func (c *hypemanBackend) APIBaseURL() string { + return c.httpScheme() + "://" + c.endpoint("api", hypemanAPIPort) +} + +func (c *hypemanBackend) CDPURL() string { + return c.wsScheme() + "://" + c.endpoint("cdp", hypemanCDPPort) + "/" +} + +func (c *hypemanBackend) CDPAddr() string { + return c.endpoint("cdp", hypemanCDPPort) +} + +func (c *hypemanBackend) ChromeDriverURL() string { + return c.httpScheme() + "://" + c.endpoint("cd", hypemanChromeDriverPort) +} + +// endpoint returns the host:port a caller should dial for a role: the ingress +// hostname on the role's listen port when hostname routing is enabled, otherwise +// the instance's private IP on the fixed guest port. +func (c *hypemanBackend) endpoint(role string, guestPort int64) string { + if c.useIngress { + return fmt.Sprintf("%s:%d", c.ingressHost(), c.listenPortFor(role)) + } + return fmt.Sprintf("%s:%d", c.ip, guestPort) +} + +func (c *hypemanBackend) httpScheme() string { + if c.useIngress && c.ingressTLS { + return "https" + } + return "http" +} + +func (c *hypemanBackend) wsScheme() string { + if c.useIngress && c.ingressTLS { + return "wss" + } + return "ws" +} + +func (c *hypemanBackend) APIClient() (*instanceoapi.ClientWithResponses, error) { + return instanceoapi.NewClientWithResponses(c.APIBaseURL()) +} + +func (c *hypemanBackend) APIClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error) { + transport := &http.Transport{DisableKeepAlives: true} + httpClient := &http.Client{Transport: transport} + return instanceoapi.NewClientWithResponses(c.APIBaseURL(), instanceoapi.WithHTTPClient(httpClient)) +} + +// WaitReady polls the instance API server's /spec.yaml until it serves 200. +func (c *hypemanBackend) WaitReady(ctx context.Context) error { + return pollHTTP200(ctx, c.APIBaseURL()+"/spec.yaml", 200*time.Millisecond) +} + +// WaitDevTools polls the CDP TCP port until it accepts connections. +func (c *hypemanBackend) WaitDevTools(ctx context.Context) error { + addr := c.CDPAddr() + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + conn, err := (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "tcp", addr) + if err == nil { + conn.Close() + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +// WaitChromeDriver polls the ChromeDriver proxy /status until it serves 200. +func (c *hypemanBackend) WaitChromeDriver(ctx context.Context) error { + return pollHTTP200(ctx, c.ChromeDriverURL()+"/status", 500*time.Millisecond) +} + +// Exec runs a command inside the guest via the instance API server's +// /process/exec endpoint, returning the exit code and combined stdout+stderr. +// +// The Docker backend invokes commands as an argv slice; we map the first +// element to Command and the remainder to Args so callers like +// Exec(ctx, []string{"sh", "-lc", "..."}) behave identically. +func (c *hypemanBackend) Exec(ctx context.Context, cmd []string) (int, string, error) { + if len(cmd) == 0 { + return -1, "", fmt.Errorf("hypeman: empty command") + } + client, err := c.APIClient() + if err != nil { + return -1, "", err + } + + body := instanceoapi.ProcessExecRequest{Command: cmd[0]} + if len(cmd) > 1 { + args := cmd[1:] + body.Args = &args + } + + resp, err := client.ProcessExecWithResponse(ctx, body) + if err != nil { + return -1, "", fmt.Errorf("hypeman: exec: %w", err) + } + if resp.JSON200 == nil { + return -1, "", fmt.Errorf("hypeman: exec returned status %d: %s", resp.StatusCode(), string(resp.Body)) + } + + out := decodeB64(resp.JSON200.StdoutB64) + decodeB64(resp.JSON200.StderrB64) + exitCode := 0 + if resp.JSON200.ExitCode != nil { + exitCode = *resp.JSON200.ExitCode + } + return exitCode, out, nil +} + +// ExitCh returns a channel that fires when the instance is stopped. +func (c *hypemanBackend) ExitCh() <-chan error { + return c.exitCh +} + +// hypemanInstanceName builds a DNS-safe, unique instance name. Hypeman requires +// lowercase letters, digits, and dashes only, not starting/ending with a dash. +func hypemanInstanceName() string { + return "ki-e2e-" + strings.ToLower(cuid2.Generate()) +} + +// isTruthy reports whether an env value means "on" (1/true/yes, case-insensitive). +func isTruthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + +// envBoolDefault parses a boolean env var, returning def when unset/empty. +func envBoolDefault(name string, def bool) bool { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return def + } + return isTruthy(v) +} + +// firstNonEmpty returns the first argument that is non-empty after trimming. +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if t := strings.TrimSpace(v); t != "" { + return t + } + } + return "" +} + +func parseCommaList(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} + +func decodeB64(s *string) string { + if s == nil || *s == "" { + return "" + } + b, err := base64.StdEncoding.DecodeString(*s) + if err != nil { + return "" + } + return string(b) +} + +// pollHTTP200 polls url until it returns HTTP 200 or ctx is done. +func pollHTTP200(ctx context.Context, url string, interval time.Duration) error { + ticker := time.NewTicker(interval) + defer ticker.Stop() + client := &http.Client{Timeout: 2 * time.Second} + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} diff --git a/server/e2e/backend_test.go b/server/e2e/backend_test.go new file mode 100644 index 00000000..060bbec7 --- /dev/null +++ b/server/e2e/backend_test.go @@ -0,0 +1,184 @@ +package e2e + +import ( + "context" + "strings" + "testing" +) + +// TestBackendKindFromEnv verifies the KI_E2E_BACKEND selection logic. These are +// cheap, infra-free unit tests safe to run in CI. +func TestBackendKindFromEnv(t *testing.T) { + cases := []struct { + name string + set bool + val string + want BackendKind + }{ + {name: "unset defaults to docker", set: false, want: BackendDocker}, + {name: "empty defaults to docker", set: true, val: "", want: BackendDocker}, + {name: "docker", set: true, val: "docker", want: BackendDocker}, + {name: "hypeman", set: true, val: "hypeman", want: BackendHypeman}, + {name: "case-insensitive + trimmed", set: true, val: " HYPEMAN ", want: BackendHypeman}, + {name: "unknown passes through", set: true, val: "bogus", want: BackendKind("bogus")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.set { + t.Setenv(envBackendKind, tc.val) + } else { + // t.Setenv requires a value; ensure the var is empty for the + // "unset" case by setting it to empty, which the function + // treats as the default. + t.Setenv(envBackendKind, "") + } + if got := backendKindFromEnv(); got != tc.want { + t.Fatalf("backendKindFromEnv() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestNewHypemanBackendRequiresConfig ensures the hypeman backend fails fast and +// with an actionable message when connection details are missing. +func TestNewHypemanBackendRequiresConfig(t *testing.T) { + if _, err := newHypemanBackend("some/image:tag", hypemanConfig{}); err == nil { + t.Fatal("expected error when base URL/token are empty, got nil") + } + if _, err := newHypemanBackend("some/image:tag", hypemanConfig{BaseURL: "http://x"}); err == nil { + t.Fatal("expected error when token is empty, got nil") + } +} + +// TestNewHypemanBackendWithConfig ensures a valid config constructs a backend +// without error — and without reading the environment. +func TestNewHypemanBackendWithConfig(t *testing.T) { + b, err := newHypemanBackend("some/image:tag", hypemanConfig{ + BaseURL: "http://hypeman.example.invalid:8080", + Token: "test-token-not-a-real-secret", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if b == nil { + t.Fatal("expected non-nil backend") + } +} + +// TestHypemanConfigFromEnv verifies env resolution happens in one place: the +// SDK-native fallbacks, the TLS default, and the comma-split GPU devices. +func TestHypemanConfigFromEnv(t *testing.T) { + t.Setenv(envHypemanBaseURL, "") + t.Setenv("HYPEMAN_BASE_URL", "https://hypeman.dev-x.kernel.sh") + t.Setenv(envHypemanToken, "") + t.Setenv("HYPEMAN_API_KEY", "tok") + t.Setenv(envHypemanIngressTLS, "") + t.Setenv(envHypemanGPUDevices, "a, b ,c") + t.Setenv(envHypemanGPUProfile, "NVIDIA L40S-2Q") + + cfg := hypemanConfigFromEnv() + if cfg.BaseURL != "https://hypeman.dev-x.kernel.sh" { + t.Errorf("BaseURL = %q (expected HYPEMAN_BASE_URL fallback)", cfg.BaseURL) + } + if cfg.Token != "tok" { + t.Errorf("Token = %q (expected HYPEMAN_API_KEY fallback)", cfg.Token) + } + if !cfg.IngressTLS { + t.Errorf("IngressTLS = false, want default true") + } + if len(cfg.GPUDevices) != 3 || cfg.GPUDevices[0] != "a" || cfg.GPUDevices[2] != "c" { + t.Errorf("GPUDevices = %v, want [a b c]", cfg.GPUDevices) + } + if cfg.GPUProfile != "NVIDIA L40S-2Q" { + t.Errorf("GPUProfile = %q", cfg.GPUProfile) + } +} + +// TestHypemanRawIPMode verifies endpoint derivation in the default raw-IP mode +// (no ingress domain): the private IP on the fixed guest ports. +func TestHypemanRawIPMode(t *testing.T) { + b := &hypemanBackend{ip: "10.1.2.3"} + for _, tc := range []struct{ name, got, want string }{ + {"api", b.APIBaseURL(), "http://10.1.2.3:10001"}, + {"cdp", b.CDPURL(), "ws://10.1.2.3:9222/"}, + {"cdpAddr", b.CDPAddr(), "10.1.2.3:9222"}, + {"cd", b.ChromeDriverURL(), "http://10.1.2.3:9224"}, + } { + if tc.got != tc.want { + t.Errorf("%s = %q, want %q", tc.name, tc.got, tc.want) + } + } +} + +// TestHypemanIngressRouting verifies hostname-routed endpoints (single wildcard +// hostname, roles differentiated by listen port, TLS) and the per-role ingress +// params. The instance name contains dashes, which must stay inside the single +// {instance} hostname label. +func TestHypemanIngressRouting(t *testing.T) { + const domain = "dev-yul-hypeman-1.kernel.sh" + b := &hypemanBackend{name: "ki-e2e-abc123", useIngress: true, ingressDomain: domain, ingressTLS: true} + for _, tc := range []struct{ name, got, want string }{ + {"api", b.APIBaseURL(), "https://ki-e2e-abc123." + domain + ":444"}, + {"cdp", b.CDPURL(), "wss://ki-e2e-abc123." + domain + ":9222/"}, + {"cdpAddr", b.CDPAddr(), "ki-e2e-abc123." + domain + ":9222"}, + {"cd", b.ChromeDriverURL(), "https://ki-e2e-abc123." + domain + ":9224"}, + {"wildcard", b.wildcardHost(), "{instance}." + domain}, + } { + if tc.got != tc.want { + t.Errorf("%s = %q, want %q", tc.name, tc.got, tc.want) + } + } + + // The "api" role reuses the host's :444 -> :10001 browser ingress shape. + p := b.roleIngressParams(ingressRoles[0]) + if p.Name != "ki-e2e-api" { + t.Errorf("ingress name = %q, want ki-e2e-api", p.Name) + } + if len(p.Rules) != 1 { + t.Fatalf("got %d rules, want 1", len(p.Rules)) + } + r := p.Rules[0] + if r.Match.Hostname != "{instance}."+domain { + t.Errorf("match hostname = %q", r.Match.Hostname) + } + if got := r.Match.Port.Or(0); got != 444 { + t.Errorf("match port = %d, want 444", got) + } + if r.Target.Instance != "{instance}" || r.Target.Port != hypemanAPIPort { + t.Errorf("target = %q:%d, want {instance}:%d", r.Target.Instance, r.Target.Port, hypemanAPIPort) + } +} + +// TestHypemanIngressPlaintext verifies http/ws when TLS is disabled. +func TestHypemanIngressPlaintext(t *testing.T) { + b := &hypemanBackend{name: "x", useIngress: true, ingressDomain: "d", ingressTLS: false} + if got, want := b.APIBaseURL(), "http://x.d:444"; got != want { + t.Errorf("APIBaseURL = %q, want %q", got, want) + } + if got, want := b.CDPURL(), "ws://x.d:9222/"; got != want { + t.Errorf("CDPURL = %q, want %q", got, want) + } +} + +// TestHypemanRejectsHostAccess verifies the hypeman backend refuses HostAccess +// (no host-loopback bridge for remote VMs) before doing any network I/O. +func TestHypemanRejectsHostAccess(t *testing.T) { + b := &hypemanBackend{} + err := b.Start(context.Background(), ContainerConfig{HostAccess: true}) + if err == nil || !strings.Contains(err.Error(), "HostAccess") { + t.Fatalf("expected HostAccess rejection, got %v", err) + } +} + +// TestDeriveIngressDomain strips a leading "hypeman." from the control API host. +func TestDeriveIngressDomain(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"https://hypeman.dev-yul-hypeman-1.kernel.sh", "dev-yul-hypeman-1.kernel.sh"}, + {"https://dev-yul-hypeman-1.kernel.sh", "dev-yul-hypeman-1.kernel.sh"}, + {"", ""}, + } { + if got := deriveIngressDomain(tc.in); got != tc.want { + t.Errorf("deriveIngressDomain(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/server/e2e/container.go b/server/e2e/container.go index ada4b135..790248bd 100644 --- a/server/e2e/container.go +++ b/server/e2e/container.go @@ -2,254 +2,115 @@ package e2e import ( "context" - "fmt" - "net/http" + "strings" "testing" - "time" - "github.com/docker/docker/api/types/container" - "github.com/docker/go-connections/nat" instanceoapi "github.com/kernel/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. +// TestContainer is the handle every e2e test uses to drive a browser instance. +// +// Historically this struct wrapped testcontainers-go directly. It is now a thin +// facade over a pluggable Backend (see backend.go), selected at construction +// time via the KI_E2E_BACKEND env var. The public method set is unchanged, so +// the ~24 e2e_*_test.go files that hold a *TestContainer continue to work +// without modification regardless of whether the instance runs as a local +// Docker container or a remote Hypeman VM. type TestContainer struct { - Name string - Image string - APIPort int // dynamically allocated host port -> container 10001 - CDPPort int // dynamically allocated host port -> container 9222 - ChromeDriverPort int // dynamically allocated host port -> container 9224 - ctr testcontainers.Container -} + // Image is the OCI image reference under test. + Image string -// ContainerConfig holds optional configuration for container startup. -type ContainerConfig struct { - Env map[string]string - HostAccess bool // Add host.docker.internal mapping + backend Backend } -// NewTestContainer creates a new test container placeholder. -// The actual container is started when Start() is called. +// NewTestContainer creates a new test container handle backed by the configured +// backend. The actual instance is provisioned 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, + Image: image, + backend: newBackend(tb, image), } } -// Start starts the container with the given configuration using testcontainers-go. +// Start starts the instance with the given configuration. 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", "9224/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() - - chromeDriverPort, err := ctr.MappedPort(ctx, "9224/tcp") - if err != nil { - return fmt.Errorf("failed to get ChromeDriver port: %w", err) - } - c.ChromeDriverPort = chromeDriverPort.Int() - - return nil + return c.backend.Start(ctx, cfg) } -// Stop stops and removes the container. +// Stop stops and removes the instance. func (c *TestContainer) Stop(ctx context.Context) error { - if c.ctr == nil { - return nil - } - return testcontainers.TerminateContainer(c.ctr) + return c.backend.Stop(ctx) } -// APIBaseURL returns the URL for the container's API server. +// APIBaseURL returns the URL for the instance's API server. func (c *TestContainer) APIBaseURL() string { - return fmt.Sprintf("http://127.0.0.1:%d", c.APIPort) + return c.backend.APIBaseURL() } -// CDPURL returns the WebSocket URL for the container's DevTools proxy. +// CDPURL returns the WebSocket URL for the instance's DevTools proxy. func (c *TestContainer) CDPURL() string { - return fmt.Sprintf("ws://127.0.0.1:%d/", c.CDPPort) + return c.backend.CDPURL() } -// APIClient creates an OpenAPI client for this container's API. -func (c *TestContainer) APIClient() (*instanceoapi.ClientWithResponses, error) { - return instanceoapi.NewClientWithResponses(c.APIBaseURL()) +// CDPAddr returns the TCP address for the instance's DevTools proxy. +func (c *TestContainer) CDPAddr() string { + return c.backend.CDPAddr() } -// 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 - } - } - } - } +// ChromeDriverURL returns the base HTTP URL for the instance's ChromeDriver proxy. +func (c *TestContainer) ChromeDriverURL() string { + return c.backend.ChromeDriverURL() } -// 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 +// ChromeDriverAddr returns the host:port for the instance's ChromeDriver proxy, +// derived from ChromeDriverURL (without scheme). Useful for substring assertions +// on proxy-rewritten URLs. +func (c *TestContainer) ChromeDriverAddr() string { + return strings.TrimPrefix(c.backend.ChromeDriverURL(), "http://") } -// 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) +// ChromeDriverWSURL returns the WebSocket URL (ws://host:port/path) for the +// instance's ChromeDriver proxy. path should include a leading slash. +func (c *TestContainer) ChromeDriverWSURL(path string) string { + return "ws://" + c.ChromeDriverAddr() + path +} + +// APIClient creates an OpenAPI client for this instance's API. +func (c *TestContainer) APIClient() (*instanceoapi.ClientWithResponses, error) { + return c.backend.APIClient() } // 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)) + return c.backend.APIClientNoKeepAlive() } -// 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) +// WaitReady waits for the instance's API to become ready. +func (c *TestContainer) WaitReady(ctx context.Context) error { + return c.backend.WaitReady(ctx) } -// ChromeDriverURL returns the base HTTP URL for the container's ChromeDriver proxy. -func (c *TestContainer) ChromeDriverURL() string { - return fmt.Sprintf("http://127.0.0.1:%d", c.ChromeDriverPort) +// WaitDevTools waits for the CDP WebSocket endpoint to be ready. +func (c *TestContainer) WaitDevTools(ctx context.Context) error { + return c.backend.WaitDevTools(ctx) } // WaitChromeDriver waits for the ChromeDriver proxy (and upstream ChromeDriver) -// to be ready by polling the /status endpoint. +// to be ready. func (c *TestContainer) WaitChromeDriver(ctx context.Context) error { - statusURL := c.ChromeDriverURL() + "/status" - client := &http.Client{Timeout: 2 * time.Second} - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - resp, err := client.Get(statusURL) - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - return nil - } - } - } - } + return c.backend.WaitChromeDriver(ctx) } -// Exec executes a command inside the container and returns the combined output. +// Exec executes a command inside the instance and returns the exit code and +// 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 + return c.backend.Exec(ctx, cmd) } -// Container returns the underlying testcontainers.Container for advanced usage. -func (c *TestContainer) Container() testcontainers.Container { - return c.ctr +// ExitCh returns a channel that receives when the instance exits. +func (c *TestContainer) ExitCh() <-chan error { + return c.backend.ExitCh() } diff --git a/server/e2e/e2e_bidi_test.go b/server/e2e/e2e_bidi_test.go index 730bc870..c5b89266 100644 --- a/server/e2e/e2e_bidi_test.go +++ b/server/e2e/e2e_bidi_test.go @@ -203,7 +203,7 @@ func TestBidiWebSocket(t *testing.T) { require.NoError(t, c.WaitChromeDriver(ctx), "chromedriver not ready") // Connect to BiDi WebSocket endpoint - bidiURL := fmt.Sprintf("ws://127.0.0.1:%d/session", c.ChromeDriverPort) + bidiURL := c.ChromeDriverWSURL("/session") t.Logf("connecting to BiDi endpoint: %s", bidiURL) conn, _, err := websocket.Dial(ctx, bidiURL, nil) @@ -376,7 +376,7 @@ func TestBidiHTTPSession(t *testing.T) { t.Logf("session ID: %s, webSocketUrl: %s", sessionID, wsURL) // Verify the proxy rewrote webSocketUrl to point through itself - expectedHost := fmt.Sprintf("127.0.0.1:%d", c.ChromeDriverPort) + expectedHost := c.ChromeDriverAddr() require.Contains(t, wsURL, expectedHost, "webSocketUrl should point through the proxy (expected host %s), got: %s", expectedHost, wsURL) @@ -445,7 +445,7 @@ func TestBidiPuppeteer(t *testing.T) { require.NoError(t, c.WaitReady(ctx), "api not ready") require.NoError(t, c.WaitChromeDriver(ctx), "chromedriver not ready") - endpoint := fmt.Sprintf("ws://127.0.0.1:%d/session", c.ChromeDriverPort) + endpoint := c.ChromeDriverWSURL("/session") t.Logf("running test-puppeteer-bidi.js against %s", endpoint) cmd := exec.CommandContext(ctx, "node", "test-puppeteer-bidi.js", "--endpoint", endpoint) @@ -476,7 +476,7 @@ func TestBidiVibium(t *testing.T) { require.NoError(t, c.WaitReady(ctx), "api not ready") require.NoError(t, c.WaitChromeDriver(ctx), "chromedriver not ready") - endpoint := fmt.Sprintf("ws://127.0.0.1:%d/session", c.ChromeDriverPort) + endpoint := c.ChromeDriverWSURL("/session") t.Logf("running test-vibium-bidi.js against %s", endpoint) cmd := exec.CommandContext(ctx, "node", "test-vibium-bidi.js", "--endpoint", endpoint) @@ -506,7 +506,7 @@ func TestBidiSelenium(t *testing.T) { require.NoError(t, c.WaitReady(ctx), "api not ready") require.NoError(t, c.WaitChromeDriver(ctx), "chromedriver not ready") - endpoint := fmt.Sprintf("http://127.0.0.1:%d", c.ChromeDriverPort) + endpoint := c.ChromeDriverURL() t.Logf("running test-selenium-bidi.js against %s", endpoint) cmd := exec.CommandContext(ctx, "node", "test-selenium-bidi.js", "--endpoint", endpoint) diff --git a/server/e2e/e2e_cdp_reconnect_test.go b/server/e2e/e2e_cdp_reconnect_test.go index d0e043c5..7c1ed226 100644 --- a/server/e2e/e2e_cdp_reconnect_test.go +++ b/server/e2e/e2e_cdp_reconnect_test.go @@ -456,7 +456,7 @@ func touchContainerFile(ctx context.Context, client *instanceoapi.ClientWithResp } func fetchBrowserWebSocketURL(ctx context.Context, c *TestContainer) (string, error) { - versionURL := fmt.Sprintf("http://127.0.0.1:%d/json/version", c.CDPPort) + versionURL := fmt.Sprintf("http://%s/json/version", c.CDPAddr()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, nil) if err != nil { return "", err diff --git a/server/go.mod b/server/go.mod index fd8296e9..f2518e7a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -8,7 +8,7 @@ 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/docker v28.5.2+incompatible github.com/docker/go-connections v0.6.0 github.com/euank/go-kmsg-parser/v2 v2.1.0 github.com/fsnotify/fsnotify v1.9.0 @@ -18,6 +18,7 @@ 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/kernel/hypeman-go v0.20.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 @@ -26,7 +27,7 @@ require ( 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/sync v0.18.0 golang.org/x/sys v0.39.0 golang.org/x/term v0.37.0 gopkg.in/yaml.v3 v3.0.1 @@ -34,7 +35,7 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect - github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // 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 @@ -57,6 +58,7 @@ require ( 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/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // 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 @@ -87,24 +89,31 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/woodsbury/decimal128 v1.3.0 // 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/contrib/instrumentation/net/http/otelhttp v0.61.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/sdk/metric 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/mod v0.28.0 // indirect - golang.org/x/net v0.45.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.37.0 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gorm.io/gorm v1.25.7 // indirect diff --git a/server/go.sum b/server/go.sum index bb8925ce..1950c7a2 100644 --- a/server/go.sum +++ b/server/go.sum @@ -2,8 +2,8 @@ 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/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/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= @@ -39,8 +39,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 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/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+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= @@ -118,6 +118,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kernel/hypeman-go v0.20.0 h1:9kEMjtlko5oYSETwn9v829rJBv5GpcmoYjBjhjuwnBA= +github.com/kernel/hypeman-go v0.20.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= github.com/kernel/neko/server v0.0.0-20260213021128-abe9ac59a634 h1:Pn8Zag7TMXnMPdjz136NTjpGwI7rgx++BNzsH2b4w3I= github.com/kernel/neko/server v0.0.0-20260213021128-abe9ac59a634/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= @@ -227,6 +229,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu 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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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= @@ -242,8 +254,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo 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/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 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= @@ -254,6 +266,8 @@ go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF 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/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= 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= @@ -261,11 +275,11 @@ go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pq golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -273,13 +287,13 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -311,15 +325,15 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -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/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=