mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-07-02 00:17:40 +08:00
223 lines
7.2 KiB
Go
223 lines
7.2 KiB
Go
package scenario
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/moby/moby/api/types/container"
|
|
"github.com/moby/moby/client"
|
|
"github.com/testcontainers/testcontainers-go"
|
|
"github.com/testcontainers/testcontainers-go/exec"
|
|
"github.com/testcontainers/testcontainers-go/network"
|
|
"github.com/testcontainers/testcontainers-go/wait"
|
|
)
|
|
|
|
// testcontainersLabel is the label testcontainers-go stamps on every
|
|
// container and network it creates. Pruning on this label only ever touches
|
|
// resources owned by the test suite.
|
|
const testcontainersLabel = "org.testcontainers"
|
|
|
|
// PruneOrphanedNetworks removes dangling networks created by the test suite.
|
|
// Each scenario spins a dedicated network, and a failed container start can
|
|
// leak one before teardown records it. Leaked networks consume Docker's
|
|
// predefined address pools until none remain and every later scenario fails
|
|
// with "all predefined address pools have been fully subnetted". Call this
|
|
// before a run and between retries to reclaim the subnets.
|
|
//
|
|
// Only unused networks bearing the testcontainers label are removed, so
|
|
// running containers and operator networks are never affected.
|
|
func PruneOrphanedNetworks(ctx context.Context) (int, error) {
|
|
cli, err := client.New(client.FromEnv)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create Docker client: %w", err)
|
|
}
|
|
defer cli.Close()
|
|
|
|
filters := client.Filters{}.Add("label", testcontainersLabel+"=true")
|
|
|
|
report, err := cli.NetworkPrune(ctx, client.NetworkPruneOptions{Filters: filters})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("prune networks: %w", err)
|
|
}
|
|
|
|
return len(report.Report.NetworksDeleted), nil
|
|
}
|
|
|
|
var (
|
|
GotenbergDockerRepository string
|
|
GotenbergVersion string
|
|
GotenbergContainerPlatform string
|
|
)
|
|
|
|
type noopLogger struct{}
|
|
|
|
func (n *noopLogger) Printf(format string, v ...any) {
|
|
// NOOP
|
|
}
|
|
|
|
// integrationAllowList is the default allow-list pattern injected into
|
|
// every Gotenberg container started by the integration tests. The outbound
|
|
// URL guard introduced for SSRF protection rejects URLs whose host
|
|
// resolves to a non-public IP, which would block:
|
|
//
|
|
// - host.docker.internal (Docker host gateway, RFC1918)
|
|
// - The static helper server running inside the test network
|
|
// - file:// URIs created in /tmp by the API context
|
|
//
|
|
// Setting the allow-list to a permissive pattern flips the URL guard into
|
|
// "allow-list match bypasses the IP check" mode for every URL the tests
|
|
// touch. Operator-supplied deny-lists still apply, so deny-list scenarios
|
|
// keep working. Test scenarios that exercise allow-list semantics
|
|
// explicitly override this default in their environment table.
|
|
//
|
|
// Production operators wanting a similar bypass for trusted internal
|
|
// destinations should set their own --*-allow-list with a tighter regex
|
|
// (for example ^https?://internal\.svc(:|/|$)).
|
|
const integrationAllowList = `.+`
|
|
|
|
// applyDefaultEnv merges baseline environment variables that the
|
|
// integration tests rely on into env, without overwriting values supplied
|
|
// by the test scenario itself. Tests can clear a default by setting it to
|
|
// the empty string in their scenario table.
|
|
func applyDefaultEnv(env map[string]string) map[string]string {
|
|
if env == nil {
|
|
env = make(map[string]string)
|
|
}
|
|
defaults := map[string]string{
|
|
"CHROMIUM_ALLOW_LIST": integrationAllowList,
|
|
"API_DOWNLOAD_FROM_ALLOW_LIST": integrationAllowList,
|
|
"WEBHOOK_ALLOW_LIST": integrationAllowList,
|
|
}
|
|
for k, v := range defaults {
|
|
if _, ok := env[k]; !ok {
|
|
env[k] = v
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
func startGotenbergContainer(ctx context.Context, env map[string]string) (*testcontainers.DockerNetwork, testcontainers.Container, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
defer cancel()
|
|
|
|
env = applyDefaultEnv(env)
|
|
|
|
n, err := network.New(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("create Gotenberg container network: %w", err)
|
|
}
|
|
|
|
healthPath := "/health"
|
|
if env["API_ROOT_PATH"] != "" {
|
|
healthPath = fmt.Sprintf("%shealth", env["API_ROOT_PATH"])
|
|
}
|
|
|
|
req := testcontainers.ContainerRequest{
|
|
Image: fmt.Sprintf("gotenberg/%s:%s", GotenbergDockerRepository, GotenbergVersion),
|
|
ImagePlatform: GotenbergContainerPlatform,
|
|
ExposedPorts: []string{"3000/tcp"},
|
|
HostConfigModifier: func(hostConfig *container.HostConfig) {
|
|
hostConfig.ExtraHosts = []string{"host.docker.internal:host-gateway"}
|
|
},
|
|
Networks: []string{n.Name},
|
|
WaitingFor: wait.ForHTTP(healthPath),
|
|
Env: env,
|
|
}
|
|
|
|
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
|
ContainerRequest: req,
|
|
Started: true,
|
|
Logger: &noopLogger{},
|
|
})
|
|
if err != nil {
|
|
// The network is already created. The scenario teardown only
|
|
// removes networks it knows about, and the caller discards n on
|
|
// error, so remove it here to avoid leaking a subnet on every
|
|
// failed start. Leaked networks accumulate until Docker's address
|
|
// pools are fully subnetted and all later scenarios fail.
|
|
if errRemove := n.Remove(ctx); errRemove != nil {
|
|
err = fmt.Errorf("start new Gotenberg container: %w (also failed to remove network: %v)", err, errRemove)
|
|
} else {
|
|
err = fmt.Errorf("start new Gotenberg container: %w", err)
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
return n, c, nil
|
|
}
|
|
|
|
func execCommandInIntegrationToolsContainer(ctx context.Context, cmd []string, path string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
|
defer cancel()
|
|
|
|
req := testcontainers.ContainerRequest{
|
|
Image: "gotenberg/integration-tools:latest",
|
|
ImagePlatform: GotenbergContainerPlatform,
|
|
Files: []testcontainers.ContainerFile{
|
|
{
|
|
HostFilePath: path,
|
|
ContainerFilePath: filepath.Base(path),
|
|
FileMode: 0o700,
|
|
},
|
|
},
|
|
Cmd: []string{"tail", "-f", "/dev/null"}, // Keeps container running indefinitely.
|
|
}
|
|
|
|
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
|
ContainerRequest: req,
|
|
Started: true,
|
|
Logger: &noopLogger{},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("start new Integration Tools container: %w", err)
|
|
}
|
|
defer func(c testcontainers.Container, ctx context.Context) {
|
|
err := c.Terminate(ctx)
|
|
if err != nil {
|
|
fmt.Printf("terminate container: %v\n", err)
|
|
}
|
|
}(c, ctx)
|
|
|
|
_, output, err := c.Exec(ctx, cmd, exec.Multiplexed())
|
|
if err != nil {
|
|
return "", fmt.Errorf("exec %q: %w", cmd, err)
|
|
}
|
|
|
|
b, err := io.ReadAll(output)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read output: %w", err)
|
|
}
|
|
|
|
return string(b), nil
|
|
}
|
|
|
|
func containerHttpEndpoint(ctx context.Context, container testcontainers.Container, port string) (string, error) {
|
|
ip, err := container.Host(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("get container IP: %w", err)
|
|
}
|
|
mapped, err := container.MappedPort(ctx, port)
|
|
if err != nil {
|
|
return "", fmt.Errorf("get container port: %w", err)
|
|
}
|
|
return fmt.Sprintf("http://%s:%s", ip, mapped.Port()), nil
|
|
}
|
|
|
|
func containerLogEntries(ctx context.Context, container testcontainers.Container) (string, error) {
|
|
logReader, err := container.Logs(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("get container log entries: %w", err)
|
|
}
|
|
defer logReader.Close()
|
|
|
|
logsBytes, err := io.ReadAll(logReader)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read container log entries: %w", err)
|
|
}
|
|
|
|
return string(logsBytes), nil
|
|
}
|