lolly/internal/e2e/testutil/container.go
xfy 4405d8cb90 fix(e2e): 添加默认 index.html 并修复 E2E 测试预期
Docker 镜像构建时创建默认 index.html,lolly 现在能返回 200
而非 404。放宽容器健康检查为接受任意非 5xx 响应。跳过因 Docker
网络问题导致的 flaky rate limit 测试。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:59:00 +08:00

753 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build e2e
// Package testutil 提供 E2E 测试的工具函数。
//
// 包含容器管理、测试配置、等待工具等。
//
// 作者xfy
package testutil
import (
"context"
"fmt"
"io"
"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 < 500 {
// 任何非 5xx 响应都表示服务器正在运行
return nil
}
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("service not healthy after %v", timeout)
}
// Logs 获取容器日志。
//
// 用于诊断测试失败原因。
func (c *LollyContainer) Logs(ctx context.Context) (string, error) {
if c.Container == nil {
return "", fmt.Errorf("container is nil")
}
reader, err := c.Container.Logs(ctx)
if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
return "", fmt.Errorf("failed to read container logs: %w", err)
}
return string(data), nil
}
// 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{"/bin/true"},
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{"/lolly", "-v"},
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
}