internal/pkg/docker/dockerengine/dockerengine.go (455 lines of code) (raw):
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package dockerengine provides functionality to interact with the Docker server.
package dockerengine
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
osexec "os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"github.com/aws/copilot-cli/internal/pkg/exec"
"github.com/fatih/color"
"golang.org/x/sync/errgroup"
)
// Cmd is the interface implemented by external commands.
type Cmd interface {
Run(name string, args []string, options ...exec.CmdOption) error
RunWithContext(ctx context.Context, name string, args []string, opts ...exec.CmdOption) error
}
// Operating systems and architectures supported by docker.
const (
OSLinux = "linux"
OSWindows = "windows"
ArchAMD64 = "amd64"
ArchX86 = "x86_64"
ArchARM = "arm"
ArchARM64 = "arm64"
)
const (
credStoreECRLogin = "ecr-login" // set on `credStore` attribute in docker configuration file
)
// Health states of a Container.
const (
noHealthcheck = "none" // Indicates there is no healthcheck
starting = "starting" // Starting indicates that the container is not yet ready
healthy = "healthy" // Healthy indicates that the container is running correctly
unhealthy = "unhealthy" // Unhealthy indicates that the container has a problem
)
// State of a docker container.
const (
containerStatusRunning = "running"
containerStatusExited = "exited"
)
// DockerCmdClient represents the docker client to interact with the server via external commands.
type DockerCmdClient struct {
runner Cmd
// Override in unit tests.
buf *bytes.Buffer
homePath string
lookupEnv func(string) (string, bool)
}
// New returns CmdClient to make requests against the Docker daemon via external commands.
func New(cmd Cmd) DockerCmdClient {
return DockerCmdClient{
runner: cmd,
homePath: userHomeDirectory(),
lookupEnv: os.LookupEnv,
}
}
// BuildArguments holds the arguments that can be passed while building a container.
type BuildArguments struct {
URI string // Required. Location of ECR Repo. Used to generate image name in conjunction with tag.
Tags []string // Required. List of tags to apply to the image.
Dockerfile string // Optional. One of Dockerfile or DockerfileContent is required. Dockerfile to pass to `docker build` via --file flag.
DockerfileContent string // Optional. One of Dockerfile or DockerfileContent is required. Dockerfile content to pass to `docker build` via stdin.
Context string // Optional. Build context directory to pass to `docker build`.
Target string // Optional. The target build stage to pass to `docker build`.
CacheFrom []string // Optional. Images to consider as cache sources to pass to `docker build`
Platform string // Optional. OS/Arch to pass to `docker build`.
Args map[string]string // Optional. Build args to pass via `--build-arg` flags. Equivalent to ARG directives in dockerfile.
Labels map[string]string // Required. Set metadata for an image.
}
// RunOptions holds the options for running a Docker container.
type RunOptions struct {
ImageURI string // Required. The image name to run.
Secrets map[string]string // Optional. Secrets to pass to the container as environment variables.
EnvVars map[string]string // Optional. Environment variables to pass to the container.
ContainerName string // Optional. The name for the container.
ContainerPorts map[string]string // Optional. Contains host and container ports.
Command []string // Optional. The command to run in the container.
ContainerNetwork string // Optional. Network mode for the container.
LogOptions RunLogOptions // Optional. Configure logging for output from the container
AddLinuxCapabilities []string // Optional. Adds linux capabilities to the container.
Init bool // Optional. Adds an init process as an entrypoint.
}
// RunLogOptions holds the logging configuration for Run().
type RunLogOptions struct {
Color *color.Color
Output io.Writer
LinePrefix string
}
// GenerateDockerBuildArgs returns command line arguments to be passed to the Docker build command based on the provided BuildArguments.
// Returns an error if no tags are provided for building an image.
func (in *BuildArguments) GenerateDockerBuildArgs(c DockerCmdClient) ([]string, error) {
// Tags must not be empty to build an docker image.
if len(in.Tags) == 0 {
return nil, &errEmptyImageTags{
uri: in.URI,
}
}
dfDir := in.Context
// Context wasn't specified use the Dockerfile's directory as context.
if dfDir == "" {
dfDir = filepath.Dir(in.Dockerfile)
}
args := []string{"build"}
// Add additional image tags to the docker build call.
for _, tag := range in.Tags {
args = append(args, "-t", imageName(in.URI, tag))
}
// Add cache from options.
for _, imageFrom := range in.CacheFrom {
args = append(args, "--cache-from", imageFrom)
}
// Add target option.
if in.Target != "" {
args = append(args, "--target", in.Target)
}
// Add platform option.
if in.Platform != "" {
args = append(args, "--platform", in.Platform)
}
// Plain display if we're in a CI environment.
if ci, _ := c.lookupEnv("CI"); ci == "true" {
args = append(args, "--progress", "plain")
}
// Add the "args:" override section from manifest to the docker build call.
// Collect the keys in a slice to sort for test stability.
var keys []string
for k := range in.Args {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, in.Args[k]))
}
// Add Labels to docker build call.
// Collect the keys in a slice to sort for test stability.
var labelKeys []string
for k := range in.Labels {
labelKeys = append(labelKeys, k)
}
sort.Strings(labelKeys)
for _, k := range labelKeys {
args = append(args, "--label", fmt.Sprintf("%s=%s", k, in.Labels[k]))
}
if in.DockerfileContent != "" {
args = append(args, "-")
} else {
args = append(args, dfDir, "-f", in.Dockerfile)
}
return args, nil
}
type dockerConfig struct {
CredsStore string `json:"credsStore,omitempty"`
CredHelpers map[string]string `json:"credHelpers,omitempty"`
}
// Build will run a `docker build` command for the given ecr repo URI and build arguments.
func (c DockerCmdClient) Build(ctx context.Context, in *BuildArguments, w io.Writer) error {
args, err := in.GenerateDockerBuildArgs(c)
if err != nil {
return fmt.Errorf("generate docker build args: %w", err)
}
opts := []exec.CmdOption{
exec.Stdout(w),
exec.Stderr(w),
}
if in.DockerfileContent != "" {
opts = append(opts, exec.Stdin(strings.NewReader(in.DockerfileContent)))
}
if err := c.runner.RunWithContext(ctx, "docker", args, opts...); err != nil {
return fmt.Errorf("building image: %w", err)
}
return nil
}
// Login will run a `docker login` command against the Service repository URI with the input uri and auth data.
func (c DockerCmdClient) Login(uri, username, password string) error {
err := c.runner.Run("docker",
[]string{"login", "-u", username, "--password-stdin", uri},
exec.Stdin(strings.NewReader(password)))
if err != nil {
return fmt.Errorf("authenticate to ECR: %w", err)
}
return nil
}
// Exec runs cmd in container with args and writes stderr/stdout to out.
func (c DockerCmdClient) Exec(ctx context.Context, container string, out io.Writer, cmd string, args ...string) error {
return c.runner.RunWithContext(ctx, "docker", append([]string{
"exec",
container,
cmd,
}, args...), exec.Stdout(out), exec.Stderr(out))
}
// Push pushes the images with the specified tags and ecr repository URI, and returns the image digest on success.
func (c DockerCmdClient) Push(ctx context.Context, uri string, w io.Writer, tags ...string) (digest string, err error) {
images := []string{}
for _, tag := range tags {
images = append(images, imageName(uri, tag))
}
var args []string
if ci, _ := c.lookupEnv("CI"); ci == "true" {
args = append(args, "--quiet")
}
for _, img := range images {
if err := c.runner.RunWithContext(ctx, "docker", append([]string{"push", img}, args...), exec.Stdout(w), exec.Stderr(w)); err != nil {
return "", fmt.Errorf("docker push %s: %w", img, err)
}
}
buf := new(strings.Builder)
// The container image will have the same digest regardless of the associated tag.
// Pick the first tag and get the image's digest.
// For Main container we call docker inspect --format '{{json (index .RepoDigests 0)}}' uri:latest
// For Sidecar container images we call docker inspect --format '{{json (index .RepoDigests 0)}}' uri:<sidecarname>-latest
if err := c.runner.RunWithContext(ctx, "docker", []string{"inspect", "--format", "'{{json (index .RepoDigests 0)}}'", imageName(uri, tags[0])}, exec.Stdout(buf)); err != nil {
return "", fmt.Errorf("inspect image digest for %s: %w", uri, err)
}
repoDigest := strings.Trim(strings.TrimSpace(buf.String()), `"'`) // remove new lines and quotes from output
parts := strings.SplitAfter(repoDigest, "@")
if len(parts) != 2 {
return "", fmt.Errorf("parse the digest from the repo digest '%s'", repoDigest)
}
return parts[1], nil
}
func (in *RunOptions) generateRunArguments() []string {
args := []string{"run"}
if in.ContainerName != "" {
args = append(args, "--name", in.ContainerName)
}
for hostPort, containerPort := range in.ContainerPorts {
args = append(args, "--publish", fmt.Sprintf("%s:%s", hostPort, containerPort))
}
if in.ContainerNetwork != "" {
args = append(args, "--network", fmt.Sprintf("container:%s", in.ContainerNetwork))
}
for key, value := range in.Secrets {
args = append(args, "--env", fmt.Sprintf("%s=%s", key, value))
}
for key, value := range in.EnvVars {
args = append(args, "--env", fmt.Sprintf("%s=%s", key, value))
}
for _, cap := range in.AddLinuxCapabilities {
args = append(args, "--cap-add", cap)
}
if in.Init {
args = append(args, "--init")
}
args = append(args, in.ImageURI)
if in.Command != nil && len(in.Command) > 0 {
args = append(args, in.Command...)
}
return args
}
// Run runs a Docker container with the sepcified options.
func (c DockerCmdClient) Run(ctx context.Context, options *RunOptions) error {
type exitCodeError interface {
ExitCode() int
}
// set default options
if options.LogOptions.Color == nil {
options.LogOptions.Color = color.New()
}
if options.LogOptions.Output == nil {
options.LogOptions.Output = os.Stderr
}
// Ensure only one thread is writing to Output at a time
// since we don't know if the Writer is thread safe.
mu := &sync.Mutex{}
g, ctx := errgroup.WithContext(ctx)
logger := func() io.WriteCloser {
pr, pw := io.Pipe()
g.Go(func() error {
scanner := bufio.NewScanner(pr)
for scanner.Scan() {
mu.Lock()
options.LogOptions.Color.Fprintln(options.LogOptions.Output, options.LogOptions.LinePrefix+scanner.Text())
mu.Unlock()
}
return scanner.Err()
})
return pw
}
g.Go(func() error {
// Close loggers to ensure scanner.Scan() in the logger goroutine returns.
// This is really only an issue in tests; os/exec.Cmd.Run() returns EOF to
// output streams when the command exits.
stdout := logger()
defer stdout.Close()
stderr := logger()
defer stderr.Close()
if err := c.runner.RunWithContext(ctx, "docker",
options.generateRunArguments(),
exec.Stdout(stdout),
exec.Stderr(stderr),
exec.NewProcessGroup()); err != nil {
var ec exitCodeError
if errors.As(err, &ec) {
return &ErrContainerExited{
name: options.ContainerName,
exitcode: ec.ExitCode(),
}
}
return fmt.Errorf("running container: %w", err)
}
return nil
})
return g.Wait()
}
// IsContainerRunning checks if a specific Docker container is running.
func (c DockerCmdClient) IsContainerRunning(ctx context.Context, name string) (bool, error) {
state, err := c.containerState(ctx, name)
if err != nil {
return false, err
}
switch state.Status {
case containerStatusRunning:
return true, nil
case containerStatusExited:
return false, &ErrContainerExited{name: name, exitcode: state.ExitCode}
}
return false, nil
}
// ContainerExitCode returns the exit code of a container.
func (c DockerCmdClient) ContainerExitCode(ctx context.Context, name string) (int, error) {
state, err := c.containerState(ctx, name)
if err != nil {
return 0, err
}
if state.Status == containerStatusRunning {
return 0, &ErrContainerNotExited{name: name}
}
return state.ExitCode, nil
}
// IsContainerHealthy returns true if a container health state is healthy.
func (c DockerCmdClient) IsContainerHealthy(ctx context.Context, containerName string) (bool, error) {
state, err := c.containerState(ctx, containerName)
if err != nil {
return false, err
}
if state.Status != containerStatusRunning {
return false, fmt.Errorf("container %q is not in %q state", containerName, containerStatusRunning)
}
if state.Health == nil {
return false, fmt.Errorf("healthcheck is not configured for container %q", containerName)
}
switch state.Health.Status {
case healthy:
return true, nil
case starting:
return false, nil
case unhealthy:
return false, fmt.Errorf("container %q is %q", containerName, unhealthy)
case noHealthcheck:
return false, fmt.Errorf("healthcheck is not configured for container %q", containerName)
default:
return false, fmt.Errorf("container %q had unexpected health status %q", containerName, state.Health.Status)
}
}
// ContainerState holds the status, exit code, and health information of a Docker container.
type ContainerState struct {
Status string `json:"Status"`
ExitCode int `json:"ExitCode"`
Health *struct {
Status string `json:"Status"`
}
}
// containerState retrieves the current state of a specified Docker container.
// It returns a ContainerState object and any error encountered during retrieval.
func (d *DockerCmdClient) containerState(ctx context.Context, containerName string) (ContainerState, error) {
containerID, err := d.containerID(ctx, containerName)
if err != nil {
return ContainerState{}, err
}
if containerID == "" {
return ContainerState{}, nil
}
buf := &bytes.Buffer{}
if err := d.runner.RunWithContext(ctx, "docker", []string{"inspect", "--format", "{{json .State}}", containerID}, exec.Stdout(buf)); err != nil {
return ContainerState{}, fmt.Errorf("run docker inspect: %w", err)
}
// Make sure we unmarshal a valid json string.
out := regexp.MustCompile(`{(.|\n)*}`).FindString(buf.String())
var containerState ContainerState
if err := json.Unmarshal([]byte(out), &containerState); err != nil {
return ContainerState{}, fmt.Errorf("unmarshal state of container %q:%w", containerName, err)
}
return containerState, nil
}
// containerID gets the ID of a Docker container by its name.
func (d *DockerCmdClient) containerID(ctx context.Context, containerName string) (string, error) {
buf := &bytes.Buffer{}
if err := d.runner.RunWithContext(ctx, "docker", []string{"ps", "-a", "-q", "--filter", "name=" + containerName}, exec.Stdout(buf)); err != nil {
return "", fmt.Errorf("run docker ps: %w", err)
}
return strings.TrimSpace(buf.String()), nil
}
// Stop calls `docker stop` to stop a running container.
func (c DockerCmdClient) Stop(ctx context.Context, containerID string) error {
buf := &bytes.Buffer{}
if err := c.runner.RunWithContext(ctx, "docker", []string{"stop", containerID}, exec.Stdout(buf), exec.Stderr(buf)); err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(buf.String()), err)
}
return nil
}
// Rm calls `docker rm` to remove a stopped container.
func (c DockerCmdClient) Rm(ctx context.Context, containerID string) error {
buf := &bytes.Buffer{}
if err := c.runner.RunWithContext(ctx, "docker", []string{"rm", containerID}, exec.Stdout(buf), exec.Stderr(buf)); err != nil {
return fmt.Errorf("%s: %w", strings.TrimSpace(buf.String()), err)
}
return nil
}
// CheckDockerEngineRunning will run `docker info` command to check if the docker engine is running.
func (c DockerCmdClient) CheckDockerEngineRunning() error {
if _, err := osexec.LookPath("docker"); err != nil {
return ErrDockerCommandNotFound
}
buf := &bytes.Buffer{}
err := c.runner.Run("docker", []string{"info", "-f", "{{json .}}"}, exec.Stdout(buf))
if err != nil {
return fmt.Errorf("get docker info: %w", err)
}
// Make sure we unmarshal a valid json string.
out := regexp.MustCompile(`{(.|\n)*}`).FindString(buf.String())
type dockerEngineNotRunningMsg struct {
ServerErrors []string `json:"ServerErrors"`
}
var msg dockerEngineNotRunningMsg
if err := json.Unmarshal([]byte(out), &msg); err != nil {
return fmt.Errorf("unmarshal docker info message: %w", err)
}
if len(msg.ServerErrors) == 0 {
return nil
}
return &ErrDockerDaemonNotResponsive{
msg: strings.Join(msg.ServerErrors, "\n"),
}
}
// GetPlatform will run the `docker version` command to get the OS/Arch.
func (c DockerCmdClient) GetPlatform() (os, arch string, err error) {
if _, err := osexec.LookPath("docker"); err != nil {
return "", "", ErrDockerCommandNotFound
}
buf := &bytes.Buffer{}
err = c.runner.Run("docker", []string{"version", "-f", "'{{json .Server}}'"}, exec.Stdout(buf))
if err != nil {
return "", "", fmt.Errorf("run docker version: %w", err)
}
// Make sure we unmarshal a valid json string.
out := regexp.MustCompile(`{(.|\n)*}`).FindString(buf.String())
type dockerServer struct {
OS string `json:"Os"`
Arch string `json:"Arch"`
}
var platform dockerServer
if err := json.Unmarshal([]byte(out), &platform); err != nil {
return "", "", fmt.Errorf("unmarshal docker platform: %w", err)
}
return platform.OS, platform.Arch, nil
}
func imageName(uri, tag string) string {
return fmt.Sprintf("%s:%s", uri, tag)
}
// IsEcrCredentialHelperEnabled return true if ecr-login is enabled either globally or registry level
func (c DockerCmdClient) IsEcrCredentialHelperEnabled(uri string) bool {
// Make sure the program is able to obtain the home directory
splits := strings.Split(uri, "/")
if c.homePath == "" || len(splits) == 0 {
return false
}
// Look into the default locations
pathsToTry := []string{filepath.Join(".docker", "config.json"), ".dockercfg"}
for _, path := range pathsToTry {
content, err := os.ReadFile(filepath.Join(c.homePath, path))
if err != nil {
// if we can't read the file keep going
continue
}
config, err := parseCredFromDockerConfig(content)
if err != nil {
continue
}
if config.CredsStore == credStoreECRLogin || config.CredHelpers[splits[0]] == credStoreECRLogin {
return true
}
}
return false
}
// PlatformString returns a specified of the format <os>/<arch>.
func PlatformString(os, arch string) string {
return fmt.Sprintf("%s/%s", os, arch)
}
func parseCredFromDockerConfig(config []byte) (*dockerConfig, error) {
/*
Sample docker config file
{
"credsStore" : "ecr-login",
"credHelpers": {
"dummyaccountId.dkr.ecr.region.amazonaws.com": "ecr-login"
}
}
*/
cred := dockerConfig{}
err := json.Unmarshal(config, &cred)
if err != nil {
return nil, err
}
return &cred, nil
}
func userHomeDirectory() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return home
}