diff --git a/go.mod b/go.mod index 105c01e..148594e 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/mholt/archives v0.1.5 github.com/microcosm-cc/bluemonday v1.0.27 github.com/moby/moby/api v1.54.2 + github.com/moby/moby/client v0.4.1 github.com/prometheus/client_golang v1.23.2 github.com/shirou/gopsutil/v4 v4.26.4 github.com/spf13/pflag v1.0.10 @@ -90,7 +91,6 @@ require ( github.com/minio/minlz v1.1.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect - github.com/moby/moby/client v0.4.1 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect diff --git a/test/integration/main_test.go b/test/integration/main_test.go index e96471f..d61b59b 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -3,6 +3,7 @@ package integration import ( + "context" "fmt" "os" "runtime" @@ -48,6 +49,14 @@ func TestMain(m *testing.M) { // Reset the failure collector before each run. scenario.ResetFailedScenarios() + // Reclaim subnets leaked by prior runs or failed container starts + // before they exhaust Docker's predefined address pools. + if deleted, err := scenario.PruneOrphanedNetworks(context.Background()); err != nil { + fmt.Fprintf(colors.Colored(os.Stdout), "warning: prune orphaned networks: %v\n", err) + } else if deleted > 0 { + fmt.Fprintf(colors.Colored(os.Stdout), "pruned %d orphaned network(s)\n", deleted) + } + code := godog.TestSuite{ Name: "integration", ScenarioInitializer: scenario.InitializeScenario, diff --git a/test/integration/scenario/containers.go b/test/integration/scenario/containers.go index 6ee0ac1..290f88f 100644 --- a/test/integration/scenario/containers.go +++ b/test/integration/scenario/containers.go @@ -8,12 +8,44 @@ import ( "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 @@ -101,10 +133,20 @@ func startGotenbergContainer(ctx context.Context, env map[string]string) (*testc Logger: &noopLogger{}, }) if err != nil { - err = fmt.Errorf("start new Gotenberg container: %w", err) + // 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, err + return n, c, nil } func execCommandInIntegrationToolsContainer(ctx context.Context, cmd []string, path string) (string, error) {