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 }