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 }