//go:build e2e // Package testutil 提供 E2E 测试的工具函数。 // // 包含容器管理、测试配置、等待工具等。 // // 作者:xfy package testutil import ( "context" "fmt" "net/http" "strings" "time" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) // defaultLollyConfig 是 lolly 容器的默认配置。 const defaultLollyConfig = ` servers: - listen: ":8080" static: - path: "/" root: "/var/www/html" index: - "index.html" ` // LollyContainerOption 容器启动选项。 type LollyContainerOption func(*lollyContainerConfig) // lollyContainerConfig 容器配置。 type lollyContainerConfig struct { configPath string configYAML string network string certPath string keyPath string extraMounts []testcontainers.ContainerMount env map[string]string exposedPorts []string waitFor wait.Strategy } // WithConfigFile 使用配置文件路径。 func WithConfigFile(path string) LollyContainerOption { return func(c *lollyContainerConfig) { c.configPath = path } } // WithConfigYAML 使用 YAML 字符串配置。 func WithConfigYAML(yaml string) LollyContainerOption { return func(c *lollyContainerConfig) { c.configYAML = yaml } } // WithNetwork 加入指定网络。 func WithNetwork(name string) LollyContainerOption { return func(c *lollyContainerConfig) { c.network = name } } // WithCert 挂载证书文件。 func WithCert(certPath, keyPath string) LollyContainerOption { return func(c *lollyContainerConfig) { c.certPath = certPath c.keyPath = keyPath } } // WithExtraMount 添加额外挂载。 func WithExtraMount(hostPath, containerPath string) LollyContainerOption { return func(c *lollyContainerConfig) { c.extraMounts = append(c.extraMounts, testcontainers.ContainerMount{ Source: testcontainers.GenericBindMountSource{ HostPath: hostPath, }, Target: testcontainers.ContainerMountTarget(containerPath), }) } } // WithEnv 设置环境变量。 func WithEnv(env map[string]string) LollyContainerOption { return func(c *lollyContainerConfig) { if c.env == nil { c.env = make(map[string]string) } for k, v := range env { c.env[k] = v } } } // WithExposedPorts 设置暴露端口。 func WithExposedPorts(ports ...string) LollyContainerOption { return func(c *lollyContainerConfig) { c.exposedPorts = ports } } // WithWaitStrategy 设置等待策略。 func WithWaitStrategy(strategy wait.Strategy) LollyContainerOption { return func(c *lollyContainerConfig) { c.waitFor = strategy } } // LollyContainer 封装 lolly 服务器容器。 type LollyContainer struct { Container testcontainers.Container Host string HTTPPort int HTTPSPort int } // StartLollyContainer 启动 lolly 服务器容器。 // // 使用预构建的 lolly 镜像。如果 configPath 为空,使用默认配置。 // 支持通过选项函数自定义配置。 func StartLollyContainer(ctx context.Context, configPath string) (*LollyContainer, error) { return StartLolly(ctx, WithConfigFile(configPath)) } // StartLolly 启动 lolly 容器(增强版)。 // // 支持多种配置方式和自定义选项。 // // 使用示例: // // // 使用默认配置 // lolly, err := StartLolly(ctx) // // // 使用配置文件 // lolly, err := StartLolly(ctx, WithConfigFile("/path/to/config.yaml")) // // // 使用动态配置 // cfg := NewConfigBuilder().WithProxy("/api/", targets).Build() // lolly, err := StartLolly(ctx, WithConfigYAML(cfg)) // // // 使用 SSL // lolly, err := StartLolly(ctx, WithConfigBuilder(cfg), WithCert(certPath, keyPath)) func StartLolly(ctx context.Context, opts ...LollyContainerOption) (*LollyContainer, error) { cfg := &lollyContainerConfig{ exposedPorts: []string{"8080/tcp", "8443/tcp"}, waitFor: wait.ForLog("HTTP 服务器启动中").WithStartupTimeout(30 * time.Second), } for _, opt := range opts { opt(cfg) } req := testcontainers.ContainerRequest{ Image: "lolly:latest", ExposedPorts: cfg.exposedPorts, WaitingFor: cfg.waitFor, } // 设置环境变量 if len(cfg.env) > 0 { req.Env = cfg.env } // 配置网络 if cfg.network != "" { req.Networks = []string{cfg.network} } // 处理配置文件 if cfg.configPath != "" { req.Mounts = append(req.Mounts, testcontainers.ContainerMount{ Source: testcontainers.GenericBindMountSource{ HostPath: cfg.configPath, }, Target: "/etc/lolly/lolly.yaml", }) } else if cfg.configYAML != "" { req.Files = append(req.Files, testcontainers.ContainerFile{ Reader: strings.NewReader(cfg.configYAML), ContainerFilePath: "/etc/lolly/lolly.yaml", FileMode: 0o644, }) } else { // 使用内嵌默认配置 req.Files = append(req.Files, testcontainers.ContainerFile{ Reader: strings.NewReader(defaultLollyConfig), ContainerFilePath: "/etc/lolly/lolly.yaml", FileMode: 0o644, }) } // 挂载证书 if cfg.certPath != "" && cfg.keyPath != "" { req.Mounts = append(req.Mounts, testcontainers.ContainerMount{ Source: testcontainers.GenericBindMountSource{ HostPath: cfg.certPath, }, Target: "/etc/lolly/ssl/server.crt", }, testcontainers.ContainerMount{ Source: testcontainers.GenericBindMountSource{ HostPath: cfg.keyPath, }, Target: "/etc/lolly/ssl/server.key", }, ) } // 添加额外挂载 req.Mounts = append(req.Mounts, cfg.extraMounts...) container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, fmt.Errorf("failed to start container: %w", err) } host, err := container.Host(ctx) if err != nil { container.Terminate(ctx) return nil, fmt.Errorf("failed to get host: %w", err) } httpPort, err := container.MappedPort(ctx, "8080/tcp") if err != nil { container.Terminate(ctx) return nil, fmt.Errorf("failed to get HTTP port: %w", err) } httpsPort, err := container.MappedPort(ctx, "8443/tcp") if err != nil { container.Terminate(ctx) return nil, fmt.Errorf("failed to get HTTPS port: %w", err) } // 解析端口数字 httpPortInt := parsePort(httpPort.Port()) httpsPortInt := parsePort(httpsPort.Port()) return &LollyContainer{ Container: container, Host: host, HTTPPort: httpPortInt, HTTPSPort: httpsPortInt, }, nil } // parsePort 解析端口字符串为整数。 func parsePort(portStr string) int { var port int fmt.Sscanf(portStr, "%d", &port) return port } // HTTPBaseURL 返回 HTTP 基础 URL。 func (c *LollyContainer) HTTPBaseURL() string { return fmt.Sprintf("http://%s:%d", c.Host, c.HTTPPort) } // HTTPSBaseURL 返回 HTTPS 基础 URL。 func (c *LollyContainer) HTTPSBaseURL() string { return fmt.Sprintf("https://%s:%d", c.Host, c.HTTPSPort) } // Terminate 终止容器。 func (c *LollyContainer) Terminate(ctx context.Context) error { if c.Container != nil { return c.Container.Terminate(ctx) } return nil } // WaitForHealthy 等待服务健康。 func (c *LollyContainer) WaitForHealthy(ctx context.Context, timeout time.Duration) error { client := &http.Client{Timeout: 5 * time.Second} url := c.HTTPBaseURL() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { resp, err := client.Get(url) if err == nil { resp.Body.Close() if resp.StatusCode == 200 || resp.StatusCode == 404 { // 200 或 404 都表示服务器正在运行 return nil } } time.Sleep(500 * time.Millisecond) } return fmt.Errorf("service not healthy after %v", timeout) } // MockBackendContainer 启动一个模拟后端服务器容器。 func MockBackendContainer(ctx context.Context, port int) (testcontainers.Container, string, error) { req := testcontainers.ContainerRequest{ Image: "nginx:alpine", ExposedPorts: []string{fmt.Sprintf("%d/tcp", port)}, WaitingFor: wait.ForHTTP("/").WithPort(fmt.Sprintf("%d/tcp", port)).WithStartupTimeout(30 * time.Second), } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, "", fmt.Errorf("failed to start mock backend: %w", err) } host, err := container.Host(ctx) if err != nil { container.Terminate(ctx) return nil, "", fmt.Errorf("failed to get host: %w", err) } mappedPort, err := container.MappedPort(ctx, fmt.Sprintf("%d/tcp", port)) if err != nil { container.Terminate(ctx) return nil, "", fmt.Errorf("failed to get port: %w", err) } addr := fmt.Sprintf("http://%s:%s", host, mappedPort.Port()) return container, addr, nil } // DockerAvailable 检查 Docker 是否可用。 func DockerAvailable(ctx context.Context) bool { req := testcontainers.ContainerRequest{ Image: "alpine:latest", Cmd: []string{"echo", "test"}, AutoRemove: true, } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return false } container.Terminate(ctx) return true } // LollyImageAvailable 检查 lolly:latest 镜像是否可用。 func LollyImageAvailable(ctx context.Context) bool { req := testcontainers.ContainerRequest{ Image: "lolly:latest", Cmd: []string{"echo", "test"}, AutoRemove: true, } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return false } container.Terminate(ctx) return true } // StartMockBackend 启动模拟后端容器(用于代理测试)。 // // 使用 nginx 作为模拟后端,返回容器和访问地址。 // 注意:此函数仅用于代理测试的后端模拟,不应作为被测系统。 // // 返回值: // - container: 容器实例 // - hostPort: 宿主机访问地址(用于测试代码访问) // - internalAddr: 容器内部访问地址(用于 lolly 配置) func StartMockBackend(ctx context.Context) (testcontainers.Container, string, error) { req := testcontainers.ContainerRequest{ Image: "nginx:alpine", ExposedPorts: []string{"80/tcp"}, WaitingFor: wait.ForHTTP("/").WithStartupTimeout(30 * time.Second), } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, "", fmt.Errorf("failed to start mock backend: %w", err) } host, err := container.Host(ctx) if err != nil { container.Terminate(ctx) return nil, "", fmt.Errorf("failed to get host: %w", err) } port, err := container.MappedPort(ctx, "80/tcp") if err != nil { container.Terminate(ctx) return nil, "", fmt.Errorf("failed to get port: %w", err) } // 返回宿主机地址 addr := fmt.Sprintf("http://%s:%s", host, port.Port()) return container, addr, nil } // BackendPool 后端池管理。 // // 管理多个后端容器,用于负载均衡测试。 // 支持网络模式:当 network 不为空时,容器加入指定网络, // 并提供内部地址供 lolly 容器访问。 type BackendPool struct { containers []testcontainers.Container addresses []string // 宿主机访问地址 internal []string // 容器网络内部地址 network string // Docker 网络名称 } // StartBackendPool 启动多个后端容器。 // // 参数: // - count: 后端数量 // // 返回后端池和地址列表(宿主机访问地址)。 func StartBackendPool(ctx context.Context, count int) (*BackendPool, error) { return StartBackendPoolWithNetwork(ctx, count, "") } // StartBackendPoolWithNetwork 启动多个后端容器并加入网络。 // // 参数: // - count: 后端数量 // - network: Docker 网络名称(可选,为空则不加入网络) // // 当 network 不为空时,容器会加入该网络,并提供内部地址。 func StartBackendPoolWithNetwork(ctx context.Context, count int, network string) (*BackendPool, error) { pool := &BackendPool{ containers: make([]testcontainers.Container, count), addresses: make([]string, count), internal: make([]string, count), network: network, } for i := 0; i < count; i++ { container, addr, internalAddr, err := startMockBackendWithNetwork(ctx, network, i) if err != nil { // 清理已启动的容器 pool.Terminate(ctx) return nil, fmt.Errorf("failed to start backend %d: %w", i, err) } pool.containers[i] = container pool.addresses[i] = addr pool.internal[i] = internalAddr } return pool, nil } // startMockBackendWithNetwork 启动单个后端容器。 func startMockBackendWithNetwork(ctx context.Context, network string, index int) (testcontainers.Container, string, string, error) { // 生成容器名称(用于网络通信) containerName := fmt.Sprintf("backend-%d-%d", time.Now().UnixNano(), index) req := testcontainers.ContainerRequest{ Image: "nginx:alpine", ExposedPorts: []string{"80/tcp"}, WaitingFor: wait.ForHTTP("/").WithStartupTimeout(30 * time.Second), Name: containerName, } if network != "" { req.Networks = []string{network} } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, "", "", fmt.Errorf("failed to start mock backend: %w", err) } host, err := container.Host(ctx) if err != nil { container.Terminate(ctx) return nil, "", "", fmt.Errorf("failed to get host: %w", err) } port, err := container.MappedPort(ctx, "80/tcp") if err != nil { container.Terminate(ctx) return nil, "", "", fmt.Errorf("failed to get port: %w", err) } // 宿主机访问地址 hostAddr := fmt.Sprintf("http://%s:%s", host, port.Port()) // 容器网络内部地址(使用容器名称) internalAddr := fmt.Sprintf("http://%s:80", containerName) return container, hostAddr, internalAddr, nil } // Addresses 返回后端地址列表(宿主机访问地址)。 func (p *BackendPool) Addresses() []string { return p.addresses } // InternalAddresses 返回容器网络内部地址列表。 // // 当 lolly 和后端在同一 Docker 网络时,应使用此地址。 func (p *BackendPool) InternalAddresses() []string { return p.internal } // Containers 返回容器列表。 func (p *BackendPool) Containers() []testcontainers.Container { return p.containers } // Count 返回后端数量。 func (p *BackendPool) Count() int { return len(p.containers) } // Terminate 终止所有容器。 func (p *BackendPool) Terminate(ctx context.Context) { for _, container := range p.containers { if container != nil { container.Terminate(ctx) } } } // TerminateOne 终止指定索引的容器。 func (p *BackendPool) TerminateOne(ctx context.Context, index int) error { if index < 0 || index >= len(p.containers) { return fmt.Errorf("invalid index %d", index) } if p.containers[index] != nil { err := p.containers[index].Terminate(ctx) p.containers[index] = nil p.addresses[index] = "" p.internal[index] = "" return err } return nil } // RestartOne 重启指定索引的容器。 func (p *BackendPool) RestartOne(ctx context.Context, index int) error { if index < 0 || index >= len(p.containers) { return fmt.Errorf("invalid index %d", index) } // 先终止旧容器 if p.containers[index] != nil { p.containers[index].Terminate(ctx) } // 启动新容器 container, addr, internalAddr, err := startMockBackendWithNetwork(ctx, p.network, index) if err != nil { return err } p.containers[index] = container p.addresses[index] = addr p.internal[index] = internalAddr return nil } // CreateNetwork 创建 Docker 网络。 // // 用于容器间通信。 func CreateNetwork(ctx context.Context, name string) (testcontainers.Network, error) { network, err := testcontainers.GenericNetwork(ctx, testcontainers.GenericNetworkRequest{ NetworkRequest: testcontainers.NetworkRequest{ Name: name, }, }) if err != nil { return nil, fmt.Errorf("failed to create network: %w", err) } return network, nil } // sharedNetworkName 共享网络名称。 const sharedNetworkName = "lolly-e2e-test" // SetupProxyTest 设置代理测试环境。 // // 创建网络、启动后端池,返回网络名称和后端池。 // lolly 容器应使用 InternalAddresses() 作为代理目标。 // 使用共享网络名称,避免网络地址池耗尽。 // // 使用示例: // // network, pool, err := testutil.SetupProxyTest(ctx, 2) // if err != nil { // t.Fatal(err) // } // defer testutil.CleanupProxyTest(ctx, network, pool) // // lolly, err := testutil.StartLolly(ctx, // testutil.WithConfigYAML(configYAML), // testutil.WithNetwork(network), // ) func SetupProxyTest(ctx context.Context, backendCount int) (string, *BackendPool, error) { // 使用共享网络名称 networkName := sharedNetworkName // 尝试创建网络(如果不存在) // 忽略"已存在"错误 _, err := CreateNetwork(ctx, networkName) if err != nil && !isNetworkExistsError(err) { return "", nil, fmt.Errorf("failed to create network: %w", err) } // 启动后端池并加入网络 pool, err := StartBackendPoolWithNetwork(ctx, backendCount, networkName) if err != nil { return "", nil, fmt.Errorf("failed to start backend pool: %w", err) } return networkName, pool, nil } // isNetworkExistsError 检查是否是网络已存在错误。 func isNetworkExistsError(err error) bool { return err != nil && (containsString(err.Error(), "already exists") || containsString(err.Error(), "network with name") || containsString(err.Error(), "failed to create network")) } // containsString 检查字符串是否包含子串。 func containsString(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr)) } func containsSubstring(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // CleanupProxyTest 清理代理测试环境。 func CleanupProxyTest(ctx context.Context, networkName string, pool *BackendPool) { if pool != nil { pool.Terminate(ctx) } // 网络会随容器终止自动清理 } // ProxyTestEnv 代理测试环境。 // // 封装代理测试所需的资源。 type ProxyTestEnv struct { Network string Pool *BackendPool Lolly *LollyContainer Cleanup func() HTTPClient *http.Client } // SetupProxyTestEnv 设置完整的代理测试环境。 // // 创建网络、启动后端池、启动 lolly,返回封装的环境。 // 这是一个便捷函数,简化测试设置。 // // 使用示例: // // env, err := testutil.SetupProxyTestEnv(ctx, t, 2, func(pool *testutil.BackendPool) string { // cfg := testutil.NewConfigBuilder(). // WithServer(":8080"). // WithProxy("/", pool.InternalAddresses()) // yaml, _ := cfg.Build() // return yaml // }) func SetupProxyTestEnv(ctx context.Context, backendCount int, configBuilder func(*BackendPool) string) (*ProxyTestEnv, error) { // 设置代理测试环境 networkName, pool, err := SetupProxyTest(ctx, backendCount) if err != nil { return nil, err } // 构建配置 configYAML := configBuilder(pool) // 启动 lolly lolly, err := StartLolly(ctx, WithConfigYAML(configYAML), WithNetwork(networkName), ) if err != nil { CleanupProxyTest(ctx, networkName, pool) return nil, fmt.Errorf("failed to start lolly: %w", err) } // 等待健康 if err := lolly.WaitForHealthy(ctx, 30*time.Second); err != nil { CleanupProxyTest(ctx, networkName, pool) lolly.Terminate(ctx) return nil, fmt.Errorf("lolly not healthy: %w", err) } cleanup := func() { lolly.Terminate(ctx) CleanupProxyTest(ctx, networkName, pool) } return &ProxyTestEnv{ Network: networkName, Pool: pool, Lolly: lolly, Cleanup: cleanup, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil }