github.com/adevinta/lava@v0.7.2/internal/containers/containers.go (about)

     1  // Copyright 2023 Adevinta
     2  
     3  // Package containers allows to interact with different container
     4  // engines.
     5  package containers
     6  
     7  import (
     8  	"context"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"log/slog"
    13  	"net"
    14  	"net/url"
    15  	"os"
    16  	"path"
    17  	"path/filepath"
    18  
    19  	"github.com/docker/cli/cli/command"
    20  	"github.com/docker/cli/cli/config"
    21  	"github.com/docker/cli/cli/flags"
    22  	"github.com/docker/docker/api/types"
    23  	"github.com/docker/docker/api/types/filters"
    24  	"github.com/docker/docker/api/types/image"
    25  	"github.com/docker/docker/client"
    26  	"github.com/docker/docker/pkg/archive"
    27  	"github.com/docker/go-connections/tlsconfig"
    28  )
    29  
    30  // ErrInvalidRuntime means that the provided container runtime is not
    31  // supported.
    32  var ErrInvalidRuntime = errors.New("invalid runtime")
    33  
    34  // Runtime is the container runtime.
    35  type Runtime int
    36  
    37  // Container runtimes.
    38  const (
    39  	RuntimeDockerd               Runtime = iota // Docker Engine
    40  	RuntimeDockerdDockerDesktop                 // Docker Desktop
    41  	RuntimeDockerdRancherDesktop                // Rancher Desktop (dockerd)
    42  	RuntimeDockerdPodmanDesktop                 // Podman Desktop (dockerd)
    43  )
    44  
    45  var runtimeNames = map[string]Runtime{
    46  	"Dockerd":               RuntimeDockerd,
    47  	"DockerdDockerDesktop":  RuntimeDockerdDockerDesktop,
    48  	"DockerdRancherDesktop": RuntimeDockerdRancherDesktop,
    49  	"DockerdPodmanDesktop":  RuntimeDockerdPodmanDesktop,
    50  }
    51  
    52  // ParseRuntime converts a runtime name into a [Runtime] value. It
    53  // returns error if the provided name does not match any known
    54  // container runtime.
    55  func ParseRuntime(s string) (Runtime, error) {
    56  	if rt, ok := runtimeNames[s]; ok {
    57  		return rt, nil
    58  	}
    59  	return Runtime(0), fmt.Errorf("%w: %v", ErrInvalidRuntime, s)
    60  }
    61  
    62  // GetenvRuntime gets the container runtime from the LAVA_RUNTIME
    63  // environment variable.
    64  func GetenvRuntime() (Runtime, error) {
    65  	envRuntime := os.Getenv("LAVA_RUNTIME")
    66  	if envRuntime == "" {
    67  		return RuntimeDockerd, nil
    68  	}
    69  
    70  	rt, err := ParseRuntime(envRuntime)
    71  	if err != nil {
    72  		return Runtime(0), fmt.Errorf("parse runtime: %w", err)
    73  	}
    74  	return rt, nil
    75  }
    76  
    77  // UnmarshalText decodes a runtime name into a [Runtime] value. It
    78  // returns error if the provided name does not match any known
    79  // container runtime.
    80  func (rt *Runtime) UnmarshalText(text []byte) error {
    81  	runtime, err := ParseRuntime(string(text))
    82  	if err != nil {
    83  		return err
    84  	}
    85  	*rt = runtime
    86  	return nil
    87  }
    88  
    89  // DockerdClient represents a Docker API client.
    90  type DockerdClient struct {
    91  	client.APIClient
    92  	rt Runtime
    93  }
    94  
    95  // NewDockerdClient returns a new container runtime client compatible
    96  // with the Docker API. Depending on the runtime being used (see
    97  // [Runtime]), there can be small differences. The provided runtime
    98  // allows to fine-tune the behavior of the client. This client behaves
    99  // as close as possible to the Docker CLI. It gets its configuration
   100  // from the Docker config file and honors the [Docker CLI environment
   101  // variables]. It also sets up TLS authentication if TLS is enabled.
   102  //
   103  // [Docker CLI environment variables]: https://docs.docker.com/engine/reference/commandline/cli/#environment-variables
   104  func NewDockerdClient(rt Runtime) (DockerdClient, error) {
   105  	tlsVerify := os.Getenv(client.EnvTLSVerify) != ""
   106  
   107  	var tlsopts *tlsconfig.Options
   108  	if tlsVerify {
   109  		certPath := os.Getenv(client.EnvOverrideCertPath)
   110  		if certPath == "" {
   111  			certPath = config.Dir()
   112  		}
   113  		tlsopts = &tlsconfig.Options{
   114  			CAFile:   filepath.Join(certPath, flags.DefaultCaFile),
   115  			CertFile: filepath.Join(certPath, flags.DefaultCertFile),
   116  			KeyFile:  filepath.Join(certPath, flags.DefaultKeyFile),
   117  		}
   118  	}
   119  
   120  	opts := &flags.ClientOptions{
   121  		TLS:        tlsVerify,
   122  		TLSVerify:  tlsVerify,
   123  		TLSOptions: tlsopts,
   124  	}
   125  
   126  	acpicli, err := command.NewAPIClientFromFlags(opts, config.LoadDefaultConfigFile(io.Discard))
   127  	if err != nil {
   128  		return DockerdClient{}, fmt.Errorf("new Docker API Client: %w", err)
   129  	}
   130  
   131  	cli := DockerdClient{
   132  		APIClient: acpicli,
   133  		rt:        rt,
   134  	}
   135  	return cli, nil
   136  }
   137  
   138  // Close closes the transport used by the client.
   139  func (cli *DockerdClient) Close() error {
   140  	return cli.APIClient.Close()
   141  }
   142  
   143  // DaemonHost returns the host address used by the client.
   144  func (cli *DockerdClient) DaemonHost() string {
   145  	daemonHost := cli.APIClient.DaemonHost()
   146  
   147  	u, err := url.Parse(daemonHost)
   148  	if err != nil {
   149  		slog.Warn("Docker daemon host is not a valid URL", "daemonHost", daemonHost)
   150  		return daemonHost
   151  	}
   152  
   153  	// Docker Desktop cannot share Unix sockets unless it is the
   154  	// Docker Unix socket and its path is exactly
   155  	// "/var/run/docker.sock".
   156  	if cli.rt == RuntimeDockerdDockerDesktop && u.Scheme == "unix" && path.Base(u.Path) == "docker.sock" {
   157  		return "unix:///var/run/docker.sock"
   158  	}
   159  
   160  	return daemonHost
   161  }
   162  
   163  // HostGatewayHostname returns a hostname that points to the container
   164  // engine host and is reachable from the containers.
   165  func (cli *DockerdClient) HostGatewayHostname() string {
   166  	if cli.rt == RuntimeDockerdPodmanDesktop {
   167  		return "host.containers.internal"
   168  	}
   169  	return "host.docker.internal"
   170  }
   171  
   172  // HostGatewayMapping returns the host-to-IP mapping required by the
   173  // containers to reach the container engine host. It returns an empty
   174  // string if this mapping is not required.
   175  func (cli *DockerdClient) HostGatewayMapping() string {
   176  	if cli.rt == RuntimeDockerd {
   177  		return cli.HostGatewayHostname() + ":host-gateway"
   178  	}
   179  	return ""
   180  }
   181  
   182  // HostGatewayInterfaceAddr returns the address of a local interface
   183  // that is reachable from the containers.
   184  func (cli *DockerdClient) HostGatewayInterfaceAddr() (string, error) {
   185  	if cli.rt == RuntimeDockerd {
   186  		gw, err := cli.bridgeGateway()
   187  		if err != nil {
   188  			return "", fmt.Errorf("get bridge gateway: %w", err)
   189  		}
   190  		return gw.IP.String(), nil
   191  	}
   192  	return "127.0.0.1", nil
   193  }
   194  
   195  // defaultDockerBridgeNetwork is the name of the default bridge
   196  // network in Docker.
   197  const defaultDockerBridgeNetwork = "bridge"
   198  
   199  // bridgeGateway returns the gateway of the default Docker bridge
   200  // network.
   201  func (cli *DockerdClient) bridgeGateway() (*net.IPNet, error) {
   202  	gws, err := cli.gateways(context.Background(), defaultDockerBridgeNetwork)
   203  	if err != nil {
   204  		return nil, fmt.Errorf("could not get Docker network gateway: %w", err)
   205  	}
   206  	if len(gws) != 1 {
   207  		return nil, fmt.Errorf("unexpected number of gateways: %v", len(gws))
   208  	}
   209  	return gws[0], nil
   210  }
   211  
   212  // gateways returns the gateways of the specified Docker network.
   213  func (cli *DockerdClient) gateways(ctx context.Context, network string) ([]*net.IPNet, error) {
   214  	resp, err := cli.NetworkInspect(ctx, network, types.NetworkInspectOptions{})
   215  	if err != nil {
   216  		return nil, fmt.Errorf("network inspect: %w", err)
   217  	}
   218  
   219  	var gws []*net.IPNet
   220  	for _, cfg := range resp.IPAM.Config {
   221  		_, subnet, err := net.ParseCIDR(cfg.Subnet)
   222  		if err != nil {
   223  			return nil, fmt.Errorf("invalid subnet: %v", cfg.Subnet)
   224  		}
   225  
   226  		ip := net.ParseIP(cfg.Gateway)
   227  		if ip == nil {
   228  			return nil, fmt.Errorf("invalid IP: %v", cfg.Gateway)
   229  		}
   230  
   231  		if !subnet.Contains(ip) {
   232  			return nil, fmt.Errorf("subnet mismatch: ip: %v, subnet: %v", ip, subnet)
   233  		}
   234  
   235  		subnet.IP = ip
   236  		gws = append(gws, subnet)
   237  	}
   238  	return gws, nil
   239  }
   240  
   241  // ImageBuild builds a Docker image in the context of a path using the
   242  // provided dockerfile and assigns it the specified reference. It
   243  // returns the ID of the new image.
   244  func (cli *DockerdClient) ImageBuild(ctx context.Context, path, dockerfile, ref string) (id string, err error) {
   245  	tar, err := archive.TarWithOptions(path, &archive.TarOptions{})
   246  	if err != nil {
   247  		return "", fmt.Errorf("new tar: %w", err)
   248  	}
   249  
   250  	opts := types.ImageBuildOptions{
   251  		Tags:       []string{ref},
   252  		Dockerfile: dockerfile,
   253  		Remove:     true,
   254  	}
   255  	resp, err := cli.APIClient.ImageBuild(ctx, tar, opts)
   256  	if err != nil {
   257  		return "", fmt.Errorf("image build: %w", err)
   258  	}
   259  	defer resp.Body.Close()
   260  
   261  	if _, err := io.Copy(io.Discard, resp.Body); err != nil {
   262  		return "", fmt.Errorf("read response: %w", err)
   263  	}
   264  
   265  	summ, err := cli.ImageList(context.Background(), image.ListOptions{
   266  		Filters: filters.NewArgs(filters.Arg("reference", ref)),
   267  	})
   268  	if err != nil {
   269  		return "", fmt.Errorf("image list: %w", err)
   270  	}
   271  
   272  	if len(summ) != 1 {
   273  		return "", fmt.Errorf("image list: unexpected number of images: %v", len(summ))
   274  	}
   275  
   276  	return summ[0].ID, nil
   277  }