github.com/TBD54566975/ftl@v0.219.0/internal/container/container.go (about)

     1  package container
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"strconv"
     9  	"time"
    10  
    11  	"github.com/alecthomas/types/once"
    12  	"github.com/alecthomas/types/optional"
    13  	"github.com/docker/docker/api/types"
    14  	"github.com/docker/docker/api/types/container"
    15  	"github.com/docker/docker/api/types/filters"
    16  	"github.com/docker/docker/client"
    17  	"github.com/docker/go-connections/nat"
    18  
    19  	"github.com/TBD54566975/ftl/internal/log"
    20  )
    21  
    22  var dockerClient = once.Once(func(ctx context.Context) (*client.Client, error) {
    23  	return client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
    24  })
    25  
    26  func DoesExist(ctx context.Context, name string) (bool, error) {
    27  	cli, err := dockerClient.Get(ctx)
    28  	if err != nil {
    29  		return false, err
    30  	}
    31  
    32  	containers, err := cli.ContainerList(ctx, container.ListOptions{
    33  		All:     true,
    34  		Filters: filters.NewArgs(filters.Arg("name", name)),
    35  	})
    36  	if err != nil {
    37  		return false, fmt.Errorf("failed to list containers: %w", err)
    38  	}
    39  
    40  	return len(containers) > 0, nil
    41  }
    42  
    43  // Pull pulls the given image.
    44  func Pull(ctx context.Context, image string) error {
    45  	cli, err := dockerClient.Get(ctx)
    46  	if err != nil {
    47  		return err
    48  	}
    49  
    50  	reader, err := cli.ImagePull(ctx, image, types.ImagePullOptions{})
    51  	if err != nil {
    52  		return fmt.Errorf("failed to pull %s image: %w", image, err)
    53  	}
    54  	defer reader.Close()
    55  
    56  	logger := log.FromContext(ctx)
    57  	_, err = io.Copy(logger.WriterAt(log.Info), reader)
    58  	if err != nil {
    59  		return fmt.Errorf("failed to stream pull: %w", err)
    60  	}
    61  
    62  	return nil
    63  }
    64  
    65  // Run starts a new detached container with the given image, name, port map, and (optional) volume mount.
    66  func Run(ctx context.Context, image, name string, hostPort, containerPort int, volume optional.Option[string]) error {
    67  	cli, err := dockerClient.Get(ctx)
    68  	if err != nil {
    69  		return err
    70  	}
    71  
    72  	config := container.Config{
    73  		Image: image,
    74  	}
    75  
    76  	containerNatPort := nat.Port(fmt.Sprintf("%d/tcp", containerPort))
    77  	hostConfig := container.HostConfig{
    78  		RestartPolicy: container.RestartPolicy{
    79  			Name: container.RestartPolicyAlways,
    80  		},
    81  		PortBindings: nat.PortMap{
    82  			containerNatPort: []nat.PortBinding{
    83  				{
    84  					HostPort: strconv.Itoa(hostPort),
    85  				},
    86  			},
    87  		},
    88  	}
    89  	if v, ok := volume.Get(); ok {
    90  		hostConfig.Binds = []string{v}
    91  	}
    92  
    93  	created, err := cli.ContainerCreate(ctx, &config, &hostConfig, nil, nil, name)
    94  	if err != nil {
    95  		return fmt.Errorf("failed to create %s container: %w", name, err)
    96  	}
    97  
    98  	err = cli.ContainerStart(ctx, created.ID, container.StartOptions{})
    99  	if err != nil {
   100  		return fmt.Errorf("failed to start %s container: %w", name, err)
   101  	}
   102  
   103  	return nil
   104  }
   105  
   106  // RunDB runs a new detached postgres container with the given name and exposed port.
   107  func RunDB(ctx context.Context, name string, port int) error {
   108  	cli, err := dockerClient.Get(ctx)
   109  	if err != nil {
   110  		return err
   111  	}
   112  
   113  	const containerName = "postgres"
   114  
   115  	exists, err := DoesExist(ctx, containerName)
   116  	if err != nil {
   117  		return err
   118  	}
   119  
   120  	if !exists {
   121  		err = Pull(ctx, "postgres:latest")
   122  		if err != nil {
   123  			return err
   124  		}
   125  	}
   126  
   127  	config := container.Config{
   128  		Image: "postgres:latest",
   129  		Env:   []string{"POSTGRES_PASSWORD=secret"},
   130  		User:  "postgres",
   131  		Cmd:   []string{"postgres"},
   132  		Healthcheck: &container.HealthConfig{
   133  			Test:        []string{"CMD-SHELL", "pg_isready"},
   134  			Interval:    time.Second,
   135  			Retries:     60,
   136  			Timeout:     60 * time.Second,
   137  			StartPeriod: 80 * time.Second,
   138  		},
   139  	}
   140  
   141  	hostConfig := container.HostConfig{
   142  		RestartPolicy: container.RestartPolicy{
   143  			Name: container.RestartPolicyAlways,
   144  		},
   145  		PortBindings: nat.PortMap{
   146  			"5432/tcp": []nat.PortBinding{
   147  				{
   148  					HostPort: strconv.Itoa(port),
   149  				},
   150  			},
   151  		},
   152  	}
   153  
   154  	created, err := cli.ContainerCreate(ctx, &config, &hostConfig, nil, nil, name)
   155  	if err != nil {
   156  		return fmt.Errorf("failed to create db container: %w", err)
   157  	}
   158  
   159  	err = cli.ContainerStart(ctx, created.ID, container.StartOptions{})
   160  	if err != nil {
   161  		return fmt.Errorf("failed to start db container: %w", err)
   162  	}
   163  
   164  	return nil
   165  }
   166  
   167  // Start starts an existing container with the given name.
   168  func Start(ctx context.Context, name string) error {
   169  	cli, err := dockerClient.Get(ctx)
   170  	if err != nil {
   171  		return err
   172  	}
   173  
   174  	err = cli.ContainerStart(ctx, name, container.StartOptions{})
   175  	if err != nil {
   176  		return fmt.Errorf("failed to start container: %w", err)
   177  	}
   178  
   179  	return nil
   180  }
   181  
   182  // Exec runs a command in the given container, stream to stderr. Return an error if the command fails.
   183  func Exec(ctx context.Context, name string, command ...string) error {
   184  	logger := log.FromContext(ctx)
   185  	logger.Debugf("Running command %q in container %q", command, name)
   186  
   187  	cli, err := dockerClient.Get(ctx)
   188  	if err != nil {
   189  		return err
   190  	}
   191  
   192  	exec, err := cli.ContainerExecCreate(ctx, name, types.ExecConfig{
   193  		Cmd:          command,
   194  		AttachStderr: true,
   195  		AttachStdout: true,
   196  	})
   197  	if err != nil {
   198  		return fmt.Errorf("failed to create exec: %w", err)
   199  	}
   200  
   201  	attach, err := cli.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{})
   202  	if err != nil {
   203  		return fmt.Errorf("failed to attach exec: %w", err)
   204  	}
   205  	defer attach.Close()
   206  
   207  	_, err = io.Copy(os.Stderr, attach.Reader)
   208  	if err != nil {
   209  		return fmt.Errorf("failed to stream exec: %w", err)
   210  	}
   211  
   212  	err = cli.ContainerExecStart(ctx, exec.ID, types.ExecStartCheck{})
   213  	if err != nil {
   214  		return fmt.Errorf("failed to start exec: %w", err)
   215  	}
   216  
   217  	inspect, err := cli.ContainerExecInspect(ctx, exec.ID)
   218  	if err != nil {
   219  		return fmt.Errorf("failed to inspect exec: %w", err)
   220  	}
   221  	if inspect.ExitCode != 0 {
   222  		return fmt.Errorf("exec failed with exit code %d", inspect.ExitCode)
   223  	}
   224  
   225  	return nil
   226  }
   227  
   228  // GetContainerPort returns the host TCP port of the given container's exposed port.
   229  func GetContainerPort(ctx context.Context, name string, port int) (int, error) {
   230  	cli, err := dockerClient.Get(ctx)
   231  	if err != nil {
   232  		return 0, err
   233  	}
   234  
   235  	inspect, err := cli.ContainerInspect(ctx, name)
   236  	if err != nil {
   237  		return 0, fmt.Errorf("failed to inspect container: %w", err)
   238  	}
   239  
   240  	containerPort := fmt.Sprintf("%d/tcp", port)
   241  	hostPort, ok := inspect.NetworkSettings.Ports[nat.Port(containerPort)]
   242  	if !ok {
   243  		return 0, fmt.Errorf("container port %q not found", containerPort)
   244  	}
   245  
   246  	if len(hostPort) == 0 {
   247  		return 0, fmt.Errorf("container port %q not bound", containerPort)
   248  	}
   249  
   250  	return nat.Port(hostPort[0].HostPort).Int(), nil
   251  }
   252  
   253  // PollContainerHealth polls the given container until it is healthy or the timeout is reached.
   254  func PollContainerHealth(ctx context.Context, containerName string, timeout time.Duration) error {
   255  	logger := log.FromContext(ctx)
   256  	logger.Debugf("Waiting for %s to be healthy", containerName)
   257  
   258  	cli, err := dockerClient.Get(ctx)
   259  	if err != nil {
   260  		return err
   261  	}
   262  
   263  	pollCtx, cancel := context.WithTimeout(ctx, timeout)
   264  	defer cancel()
   265  
   266  	for {
   267  		select {
   268  		case <-pollCtx.Done():
   269  			return fmt.Errorf("timed out waiting for container to be healthy: %w", pollCtx.Err())
   270  
   271  		case <-time.After(100 * time.Millisecond):
   272  			inspect, err := cli.ContainerInspect(pollCtx, containerName)
   273  			if err != nil {
   274  				return fmt.Errorf("failed to inspect container: %w", err)
   275  			}
   276  
   277  			if inspect.State.Health.Status == types.Healthy {
   278  				return nil
   279  			}
   280  		}
   281  	}
   282  }