Files
headscale/integration/dockertestutil/network.go
Kristoffer Dalby 4cca63155d all: apply godoc [Name] link conventions across comments
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.

Pattern applied across the tree:
  In-package         [Foo], [Foo.Bar]
  Cross-package      [pkg.Foo], [pkg.Foo.Bar]
  Stdlib             [netip.Prefix], [errors.Is], [context.Context]
  Tailscale          [tailcfg.MapResponse], [tailcfg.Node.CapMap],
                     [tailcfg.NodeAttrSuggestExitNode]

Skip rules:
  - File:line refs left as plain text
  - HuJSON wire keys inside backtick raw strings untouched
  - ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
    symbols, left as plain text
  - JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
    link tags, decorative dividers — all left as-is
2026-05-19 09:55:22 +02:00

486 lines
14 KiB
Go

package dockertestutil
import (
"bytes"
"context"
"errors"
"fmt"
"log"
"net"
"strings"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
)
var (
ErrContainerNotFound = errors.New("container not found")
ErrConditionTimeout = errors.New("condition not met within timeout")
)
// retryDockerOp absorbs eventual-consistency races in libnetwork endpoint cleanup.
// Pulls its backoff bounds from retry.go so every helper that drives a
// docker control-plane call uses the same budget.
func retryDockerOp(ctx context.Context, op func() error) error {
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = DockerOpInitialInterval
bo.MaxInterval = DockerOpMaxInterval
_, err := backoff.Retry(ctx, func() (struct{}, error) {
err := op()
if err != nil {
return struct{}{}, err
}
return struct{}{}, nil
}, backoff.WithBackOff(bo), backoff.WithMaxElapsedTime(DockerOpMaxElapsedTime))
return err
}
func GetFirstOrCreateNetwork(pool *dockertest.Pool, name string) (*dockertest.Network, error) {
return GetFirstOrCreateNetworkWithSubnet(pool, name, "")
}
// GetFirstOrCreateNetworkWithSubnet creates a Docker network with an optional
// custom subnet. When subnet is empty, Docker auto-assigns from its default
// pool. Use RFC 5737 TEST-NET ranges (e.g. "198.51.100.0/24") for networks
// that need to be reachable through Tailscale exit nodes, since Tailscale's
// shrinkDefaultRoute strips RFC1918 ranges from exit node forwarding filters.
func GetFirstOrCreateNetworkWithSubnet(pool *dockertest.Pool, name, subnet string) (*dockertest.Network, error) {
networks, err := pool.NetworksByName(name)
if err != nil {
return nil, fmt.Errorf("looking up network names: %w", err)
}
if len(networks) == 0 {
var opts []func(*docker.CreateNetworkOptions)
if subnet != "" {
opts = append(opts, func(config *docker.CreateNetworkOptions) {
config.IPAM = &docker.IPAMOptions{
Config: []docker.IPAMConfig{
{Subnet: subnet},
},
}
})
}
if _, err := pool.CreateNetwork(name, opts...); err == nil { //nolint:noinlineerr // intentional inline check
// Create does not give us an updated version of the resource, so we need to
// get it again.
networks, err := pool.NetworksByName(name)
if err != nil {
return nil, err
}
return &networks[0], nil
} else {
return nil, fmt.Errorf("creating network: %w", err)
}
}
return &networks[0], nil
}
func AddContainerToNetwork(
pool *dockertest.Pool,
network *dockertest.Network,
testContainer string,
) error {
containers, err := pool.Client.ListContainers(docker.ListContainersOptions{
All: true,
Filters: map[string][]string{
"name": {testContainer},
},
})
if err != nil {
return err
}
// TODO(kradalby): This doesn't work reliably, but calling the exact same functions
// seem to work fine...
// if container, ok := pool.ContainerByName("/" + testContainer); ok {
// err := container.ConnectToNetwork(network)
// if err != nil {
// return err
// }
// }
return retryDockerOp(context.Background(), func() error {
return pool.Client.ConnectNetwork(network.Network.ID, docker.NetworkConnectionOptions{
Container: containers[0].ID,
})
})
}
// DisconnectContainerFromNetwork detaches the container at the docker
// daemon level (cable-pull semantics) and waits for libnetwork to drop
// the endpoint before returning — re-attaching during the
// reprogramming window otherwise fails with "network is unreachable".
func DisconnectContainerFromNetwork(
pool *dockertest.Pool,
network *dockertest.Network,
testContainer string,
) error {
containerID, err := lookupContainerID(pool, testContainer)
if err != nil {
return err
}
err = retryDockerOp(context.Background(), func() error {
return pool.Client.DisconnectNetwork(network.Network.ID, docker.NetworkConnectionOptions{
Container: containerID,
})
})
if err != nil {
return err
}
err = waitNetworkContainerAbsent(pool, network, testContainer, DockerOpMaxElapsedTime)
if err != nil {
return err
}
// libnetwork drops the endpoint from its model before the kernel
// netns has flushed the matching route. Re-attach with the sticky
// IP otherwise fails with "conflicts with existing route".
return waitContainerRouteAbsent(pool, containerID, network, DockerOpMaxElapsedTime)
}
// ReconnectContainerToNetwork is the inverse of
// [DisconnectContainerFromNetwork] — re-attaches the container to the
// network so traffic can flow again.
func ReconnectContainerToNetwork(
pool *dockertest.Pool,
network *dockertest.Network,
testContainer string,
) error {
containerID, err := lookupContainerID(pool, testContainer)
if err != nil {
return err
}
err = retryDockerOp(context.Background(), func() error {
connectErr := pool.Client.ConnectNetwork(network.Network.ID, docker.NetworkConnectionOptions{
Container: containerID,
})
if connectErr != nil && isStaleRouteConflict(connectErr) {
// Defensive cleanup: a route survived the netns flush
// despite the wait above. Drop subnet routes that point
// at the disconnected interface so libnetwork can
// reprogram the sticky IP, then let the retry budget
// try the ConnectNetwork call again.
removeContainerSubnetRoutes(pool, containerID, network)
}
return connectErr
})
if err != nil {
return err
}
return waitNetworkContainerPresent(pool, network, testContainer, DockerOpMaxElapsedTime)
}
// lookupContainerID resolves a container name to its docker ID.
func lookupContainerID(pool *dockertest.Pool, testContainer string) (string, error) {
containers, err := pool.Client.ListContainers(docker.ListContainersOptions{
All: true,
Filters: map[string][]string{"name": {testContainer}},
})
if err != nil {
return "", err
}
if len(containers) == 0 {
return "", fmt.Errorf("%w: %s", ErrContainerNotFound, testContainer)
}
return containers[0].ID, nil
}
// DisconnectAndReconnect calls Disconnect followed by Reconnect; both
// primitives drive their own libnetwork settle waits.
func DisconnectAndReconnect(
pool *dockertest.Pool,
network *dockertest.Network,
testContainer string,
) error {
err := DisconnectContainerFromNetwork(pool, network, testContainer)
if err != nil {
return fmt.Errorf("disconnecting %s from %s: %w", testContainer, network.Network.Name, err)
}
err = ReconnectContainerToNetwork(pool, network, testContainer)
if err != nil {
return fmt.Errorf("reconnecting %s to %s: %w", testContainer, network.Network.Name, err)
}
return nil
}
func waitNetworkContainerAbsent(
pool *dockertest.Pool,
network *dockertest.Network,
testContainer string,
timeout time.Duration,
) error {
return pollUntil(timeout, func() (bool, error) {
net, err := pool.Client.NetworkInfo(network.Network.ID)
if err != nil {
return false, fmt.Errorf("inspecting network %s: %w", network.Network.Name, err)
}
for _, c := range net.Containers {
if c.Name == testContainer || c.Name == "/"+testContainer {
return false, nil
}
}
return true, nil
})
}
func waitNetworkContainerPresent(
pool *dockertest.Pool,
network *dockertest.Network,
testContainer string,
timeout time.Duration,
) error {
return pollUntil(timeout, func() (bool, error) {
net, err := pool.Client.NetworkInfo(network.Network.ID)
if err != nil {
return false, fmt.Errorf("inspecting network %s: %w", network.Network.Name, err)
}
for _, c := range net.Containers {
if (c.Name == testContainer || c.Name == "/"+testContainer) && c.IPv4Address != "" {
return true, nil
}
}
return false, nil
})
}
// waitContainerRouteAbsent polls the container's routing table until no
// route remains for the network's IPAM subnet. libnetwork's docker-side
// endpoint teardown is asynchronous from the kernel netns flush, and a
// surviving route blocks a subsequent reconnect at sticky-IP assignment
// with "conflicts with existing route".
func waitContainerRouteAbsent(pool *dockertest.Pool, containerID string, network *dockertest.Network, timeout time.Duration) error {
subnets := networkSubnets(network)
if len(subnets) == 0 {
return nil
}
return pollUntil(timeout, func() (bool, error) {
stdout, err := execStdout(pool, containerID, []string{"ip", "-4", "route", "show"})
if err != nil {
return false, fmt.Errorf("inspecting routes in %s: %w", containerID, err)
}
for _, subnet := range subnets {
if strings.Contains(stdout, subnet+" ") || strings.HasSuffix(strings.TrimSpace(stdout), subnet) {
return false, nil
}
}
return true, nil
})
}
// removeContainerSubnetRoutes drops residue subnet routes in the
// container's netns — the leftover that libnetwork's async endpoint
// teardown can leave behind.
func removeContainerSubnetRoutes(pool *dockertest.Pool, containerID string, network *dockertest.Network) {
for _, subnet := range networkSubnets(network) {
_, err := execStdout(pool, containerID, []string{"ip", "-4", "route", "del", subnet})
if err != nil {
log.Printf("removing stale route %s in %s: %v", subnet, containerID, err)
}
}
}
// isStaleRouteConflict matches the libnetwork 500 raised when a
// surviving subnet route blocks sticky-IP reprogramming on reconnect.
func isStaleRouteConflict(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "conflicts with existing route")
}
// networkSubnets returns the IPAM-configured subnets for a docker
// network. Empty when IPAM is left to docker defaults.
func networkSubnets(network *dockertest.Network) []string {
out := make([]string, 0, len(network.Network.IPAM.Config))
for _, cfg := range network.Network.IPAM.Config {
if cfg.Subnet != "" {
out = append(out, cfg.Subnet)
}
}
return out
}
// execStdout runs a one-shot command in containerID and returns stdout.
func execStdout(pool *dockertest.Pool, containerID string, cmd []string) (string, error) {
exec, err := pool.Client.CreateExec(docker.CreateExecOptions{
Container: containerID,
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return "", fmt.Errorf("create exec: %w", err)
}
var stdout, stderr bytes.Buffer
err = pool.Client.StartExec(exec.ID, docker.StartExecOptions{
OutputStream: &stdout,
ErrorStream: &stderr,
})
if err != nil {
return stdout.String(), fmt.Errorf("start exec: %w", err)
}
return stdout.String(), nil
}
// pollUntil ticks every DockerOpInitialInterval until check returns
// done=true or timeout elapses. A non-nil check error aborts the loop.
func pollUntil(timeout time.Duration, check func() (done bool, err error)) error {
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(DockerOpInitialInterval)
defer ticker.Stop()
for {
done, err := check()
if err != nil {
return err
}
if done {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("%w: %s", ErrConditionTimeout, timeout)
}
<-ticker.C
}
}
// RandomFreeHostPort asks the kernel for a free open port that is ready to use.
// (from https://github.com/phayes/freeport)
func RandomFreeHostPort() (int, error) {
addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
if err != nil {
return 0, err
}
listener, err := net.ListenTCP("tcp", addr)
if err != nil {
return 0, err
}
defer listener.Close()
//nolint:forcetypeassert
return listener.Addr().(*net.TCPAddr).Port, nil
}
// CleanUnreferencedNetworks removes networks that are not referenced by any containers.
func CleanUnreferencedNetworks(pool *dockertest.Pool) error {
filter := "name=hs-"
networks, err := pool.NetworksByName(filter)
if err != nil {
return fmt.Errorf("getting networks by filter %q: %w", filter, err)
}
for _, network := range networks {
if len(network.Network.Containers) == 0 {
err := pool.RemoveNetwork(&network)
if err != nil {
log.Printf("removing network %s: %s", network.Network.Name, err)
}
}
}
return nil
}
// CleanImagesInCI removes images if running in CI.
// It only removes dangling (untagged) images to avoid forcing rebuilds.
// Tagged images (golang:*, tailscale/tailscale:*, etc.) are automatically preserved.
func CleanImagesInCI(pool *dockertest.Pool) error {
if !util.IsCI() {
log.Println("Skipping image cleanup outside of CI")
return nil
}
images, err := pool.Client.ListImages(docker.ListImagesOptions{})
if err != nil {
return fmt.Errorf("getting images: %w", err)
}
removedCount := 0
for _, image := range images {
// Only remove dangling (untagged) images to avoid forcing rebuilds
// Dangling images have no RepoTags or only have "<none>:<none>"
if len(image.RepoTags) == 0 || (len(image.RepoTags) == 1 && image.RepoTags[0] == "<none>:<none>") {
log.Printf("Removing dangling image: %s", image.ID[:12])
err := pool.Client.RemoveImage(image.ID)
if err != nil {
log.Printf("Warning: failed to remove image %s: %v", image.ID[:12], err)
} else {
removedCount++
}
}
}
if removedCount > 0 {
log.Printf("Removed %d dangling images in CI", removedCount)
} else {
log.Println("No dangling images to remove in CI")
}
return nil
}
// DockerRestartPolicy sets the restart policy for containers.
func DockerRestartPolicy(config *docker.HostConfig) {
config.RestartPolicy = docker.RestartPolicy{
Name: "unless-stopped",
}
}
// DockerAllowLocalIPv6 allows IPv6 traffic within the container.
func DockerAllowLocalIPv6(config *docker.HostConfig) {
config.NetworkMode = "default"
config.Sysctls = map[string]string{
"net.ipv6.conf.all.disable_ipv6": "0",
}
}
// DockerAllowNetworkAdministration gives the container network administration capabilities.
func DockerAllowNetworkAdministration(config *docker.HostConfig) {
config.CapAdd = append(config.CapAdd, "NET_ADMIN")
config.Privileged = true
}
// DockerMemoryLimit sets memory limit and disables OOM kill for containers.
func DockerMemoryLimit(config *docker.HostConfig) {
config.Memory = 2 * 1024 * 1024 * 1024 // 2GB in bytes
config.OOMKillDisable = true
}