github.com/Redstoneguy129/cli@v0.0.0-20230211220159-15dca4e91917/internal/utils/docker.go (about) 1 package utils 2 3 import ( 4 "archive/tar" 5 "bytes" 6 "context" 7 "encoding/base64" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "io" 12 "os" 13 "strings" 14 "sync" 15 "time" 16 17 "github.com/docker/cli/cli/compose/loader" 18 dockerConfig "github.com/docker/cli/cli/config" 19 "github.com/docker/cli/cli/streams" 20 "github.com/docker/docker/api/types" 21 "github.com/docker/docker/api/types/container" 22 "github.com/docker/docker/api/types/mount" 23 "github.com/docker/docker/client" 24 "github.com/docker/docker/errdefs" 25 "github.com/docker/docker/pkg/jsonmessage" 26 "github.com/docker/docker/pkg/stdcopy" 27 "github.com/spf13/viper" 28 ) 29 30 // TODO: refactor to initialise lazily 31 var Docker = NewDocker() 32 33 func NewDocker() *client.Client { 34 docker, err := client.NewClientWithOpts( 35 client.WithAPIVersionNegotiation(), 36 // Support env (e.g. for mock setup or rootless docker) 37 client.FromEnv, 38 ) 39 if err != nil { 40 fmt.Fprintln(os.Stderr, "Failed to initialize Docker client:", err) 41 os.Exit(1) 42 } 43 return docker 44 } 45 46 func AssertDockerIsRunning(ctx context.Context) error { 47 if _, err := Docker.Ping(ctx); err != nil { 48 return NewError(err.Error()) 49 } 50 51 return nil 52 } 53 54 func DockerNetworkCreateIfNotExists(ctx context.Context, networkId string) error { 55 _, err := Docker.NetworkCreate( 56 ctx, 57 networkId, 58 types.NetworkCreate{ 59 CheckDuplicate: true, 60 Labels: map[string]string{ 61 "com.supabase.cli.project": Config.ProjectId, 62 "com.docker.compose.project": Config.ProjectId, 63 }, 64 }, 65 ) 66 // if error is network already exists, no need to propagate to user 67 if errdefs.IsConflict(err) { 68 return nil 69 } 70 return err 71 } 72 73 func DockerExec(ctx context.Context, container string, cmd []string) (io.Reader, error) { 74 exec, err := Docker.ContainerExecCreate( 75 ctx, 76 container, 77 types.ExecConfig{Cmd: cmd, AttachStderr: true, AttachStdout: true}, 78 ) 79 if err != nil { 80 return nil, err 81 } 82 83 resp, err := Docker.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{}) 84 if err != nil { 85 return nil, err 86 } 87 88 return resp.Reader, nil 89 } 90 91 // Used by unit tests 92 // NOTE: There's a risk of data race with reads & writes from `DockerRun` and 93 // reads from `DockerRemoveAll`, but since they're expected to be run on the 94 // same thread, this is fine. 95 var ( 96 Containers []string 97 Volumes []string 98 ) 99 100 func WaitAll(containers []string, exec func(container string)) { 101 var wg sync.WaitGroup 102 for _, container := range containers { 103 wg.Add(1) 104 go func(container string) { 105 defer wg.Done() 106 exec(container) 107 }(container) 108 } 109 wg.Wait() 110 } 111 112 func DockerRemoveAll(ctx context.Context) { 113 WaitAll(Containers, func(container string) { 114 _ = Docker.ContainerRemove(ctx, container, types.ContainerRemoveOptions{ 115 RemoveVolumes: true, 116 Force: true, 117 }) 118 }) 119 WaitAll(Volumes, func(name string) { 120 _ = Docker.VolumeRemove(ctx, name, true) 121 }) 122 _ = Docker.NetworkRemove(ctx, NetId) 123 } 124 125 func DockerAddFile(ctx context.Context, container string, fileName string, content []byte) error { 126 var buf bytes.Buffer 127 tw := tar.NewWriter(&buf) 128 err := tw.WriteHeader(&tar.Header{ 129 Name: fileName, 130 Mode: 0777, 131 Size: int64(len(content)), 132 }) 133 134 if err != nil { 135 return fmt.Errorf("failed to copy file: %v", err) 136 } 137 138 _, err = tw.Write(content) 139 140 if err != nil { 141 return fmt.Errorf("failed to copy file: %v", err) 142 } 143 144 err = tw.Close() 145 146 if err != nil { 147 return fmt.Errorf("failed to copy file: %v", err) 148 } 149 150 err = Docker.CopyToContainer(ctx, container, "/tmp", &buf, types.CopyToContainerOptions{}) 151 if err != nil { 152 return fmt.Errorf("failed to copy file: %v", err) 153 } 154 return nil 155 } 156 157 var ( 158 // Only supports one registry per command invocation 159 registryAuth string 160 registryOnce sync.Once 161 ) 162 163 func GetRegistryAuth() string { 164 registryOnce.Do(func() { 165 config := dockerConfig.LoadDefaultConfigFile(os.Stderr) 166 // Ref: https://docs.docker.com/engine/api/sdk/examples/#pull-an-image-with-authentication 167 auth, err := config.GetAuthConfig(getRegistry()) 168 if err != nil { 169 fmt.Fprintln(os.Stderr, "Failed to load registry credentials:", err) 170 return 171 } 172 encoded, err := json.Marshal(auth) 173 if err != nil { 174 fmt.Fprintln(os.Stderr, "Failed to serialise auth config:", err) 175 return 176 } 177 registryAuth = base64.URLEncoding.EncodeToString(encoded) 178 }) 179 return registryAuth 180 } 181 182 // Defaults to Supabase public ECR for faster image pull 183 const defaultRegistry = "public.ecr.aws" 184 185 func getRegistry() string { 186 registry := viper.GetString("INTERNAL_IMAGE_REGISTRY") 187 if len(registry) == 0 { 188 return defaultRegistry 189 } 190 return strings.ToLower(registry) 191 } 192 193 func GetRegistryImageUrl(imageName string) string { 194 registry := getRegistry() 195 if registry == "docker.io" { 196 return imageName 197 } 198 // Configure mirror registry 199 parts := strings.Split(imageName, "/") 200 imageName = parts[len(parts)-1] 201 return registry + "/supabase/" + imageName 202 } 203 204 func DockerImagePull(ctx context.Context, image string, w io.Writer) error { 205 out, err := Docker.ImagePull(ctx, image, types.ImagePullOptions{ 206 RegistryAuth: GetRegistryAuth(), 207 }) 208 if err != nil { 209 return err 210 } 211 defer out.Close() 212 return jsonmessage.DisplayJSONMessagesToStream(out, streams.NewOut(w), nil) 213 } 214 215 // Used by unit tests 216 var timeUnit = time.Second 217 218 func DockerImagePullWithRetry(ctx context.Context, image string, retries int) error { 219 err := DockerImagePull(ctx, image, os.Stderr) 220 for i := 0; i < retries; i++ { 221 if err == nil { 222 break 223 } 224 fmt.Fprintln(os.Stderr, err) 225 period := time.Duration(2<<(i+1)) * timeUnit 226 fmt.Fprintf(os.Stderr, "Retrying after %v: %s\n", period, image) 227 time.Sleep(period) 228 err = DockerImagePull(ctx, image, os.Stderr) 229 } 230 return err 231 } 232 233 func DockerPullImageIfNotCached(ctx context.Context, imageName string) error { 234 imageUrl := GetRegistryImageUrl(imageName) 235 if _, _, err := Docker.ImageInspectWithRaw(ctx, imageUrl); err == nil { 236 return nil 237 } else if !client.IsErrNotFound(err) { 238 return err 239 } 240 return DockerImagePullWithRetry(ctx, imageUrl, 2) 241 } 242 243 func DockerStop(containerID string) { 244 if err := Docker.ContainerStop(context.Background(), containerID, nil); err != nil { 245 fmt.Fprintln(os.Stderr, "Failed to stop container:", containerID, err) 246 } 247 } 248 249 func DockerStart(ctx context.Context, config container.Config, hostConfig container.HostConfig, containerName string) (string, error) { 250 // Pull container image 251 if err := DockerPullImageIfNotCached(ctx, config.Image); err != nil { 252 return "", err 253 } 254 // Setup default config 255 config.Image = GetRegistryImageUrl(config.Image) 256 if config.Labels == nil { 257 config.Labels = map[string]string{} 258 } 259 config.Labels["com.supabase.cli.project"] = Config.ProjectId 260 config.Labels["com.docker.compose.project"] = Config.ProjectId 261 if len(hostConfig.NetworkMode) == 0 { 262 hostConfig.NetworkMode = container.NetworkMode(NetId) 263 } 264 // Create network with name 265 if err := DockerNetworkCreateIfNotExists(ctx, string(hostConfig.NetworkMode)); err != nil { 266 return "", err 267 } 268 // Create container from image 269 resp, err := Docker.ContainerCreate(ctx, &config, &hostConfig, nil, nil, containerName) 270 if err != nil { 271 return "", err 272 } 273 // Track container id for cleanup 274 Containers = append(Containers, resp.ID) 275 for _, bind := range hostConfig.Binds { 276 spec, err := loader.ParseVolume(bind) 277 if err != nil { 278 return "", err 279 } 280 // Track named volumes for cleanup 281 if len(spec.Source) > 0 && spec.Type == string(mount.TypeVolume) { 282 Volumes = append(Volumes, spec.Source) 283 } 284 } 285 // Run container in background 286 return resp.ID, Docker.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}) 287 } 288 289 func DockerRemove(containerId string) { 290 if err := Docker.ContainerRemove(context.Background(), containerId, types.ContainerRemoveOptions{ 291 RemoveVolumes: true, 292 Force: true, 293 }); err != nil { 294 fmt.Fprintln(os.Stderr, "Failed to remove container:", containerId, err) 295 } 296 } 297 298 // Runs a container image exactly once, returning stdout and throwing error on non-zero exit code. 299 func DockerRunOnce(ctx context.Context, image string, env []string, cmd []string) (string, error) { 300 stderr := io.Discard 301 if viper.GetBool("DEBUG") { 302 stderr = os.Stderr 303 } 304 var out bytes.Buffer 305 err := DockerRunOnceWithStream(ctx, image, env, cmd, nil, "", &out, stderr) 306 return out.String(), err 307 } 308 309 func DockerRunOnceWithStream(ctx context.Context, image string, env, cmd, binds []string, containerName string, stdout, stderr io.Writer) error { 310 // Cannot rely on docker's auto remove because 311 // 1. We must inspect exit code after container stops 312 // 2. Context cancellation may happen after start 313 container, err := DockerStart(ctx, container.Config{ 314 Image: image, 315 Env: env, 316 Cmd: cmd, 317 }, container.HostConfig{ 318 Binds: binds, 319 // Allows containerized functions on Linux to reach host OS 320 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 321 }, containerName) 322 if err != nil { 323 return err 324 } 325 defer DockerRemove(container) 326 // Stream logs 327 logs, err := Docker.ContainerLogs(ctx, container, types.ContainerLogsOptions{ 328 ShowStdout: true, 329 ShowStderr: true, 330 Follow: true, 331 }) 332 if err != nil { 333 return err 334 } 335 defer logs.Close() 336 if _, err := stdcopy.StdCopy(stdout, stderr, logs); err != nil { 337 return err 338 } 339 // Check exit code 340 resp, err := Docker.ContainerInspect(ctx, container) 341 if err != nil { 342 return err 343 } 344 if resp.State.ExitCode > 0 { 345 return fmt.Errorf("error running container: exit %d", resp.State.ExitCode) 346 } 347 return nil 348 } 349 350 // Exec a command once inside a container, returning stdout and throwing error on non-zero exit code. 351 func DockerExecOnce(ctx context.Context, container string, env []string, cmd []string) (string, error) { 352 // Reset shadow database 353 exec, err := Docker.ContainerExecCreate(ctx, container, types.ExecConfig{ 354 Env: env, 355 Cmd: cmd, 356 AttachStderr: viper.GetBool("DEBUG"), 357 AttachStdout: true, 358 }) 359 if err != nil { 360 return "", err 361 } 362 // Read exec output 363 resp, err := Docker.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{}) 364 if err != nil { 365 return "", err 366 } 367 defer resp.Close() 368 // Capture error details 369 var out bytes.Buffer 370 if _, err := stdcopy.StdCopy(&out, os.Stderr, resp.Reader); err != nil { 371 return "", err 372 } 373 // Get the exit code 374 iresp, err := Docker.ContainerExecInspect(ctx, exec.ID) 375 if err != nil { 376 return "", err 377 } 378 if iresp.ExitCode > 0 { 379 err = errors.New("error executing command") 380 } 381 return out.String(), err 382 }