asteris-llc/converge

View on GitHub
resource/docker/container/container.go

Summary

Maintainability
A
2 hrs
Test Coverage
// Copyright © 2016 Asteris, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// +build !solaris

package container

import (
    "fmt"
    "path"
    "sort"
    "strconv"
    "strings"

    "github.com/asteris-llc/converge/resource"
    "github.com/asteris-llc/converge/resource/docker"
    mapset "github.com/deckarep/golang-set"
    dc "github.com/fsouza/go-dockerclient"
    "github.com/pkg/errors"
    "golang.org/x/net/context"
)

const (
    containerStatusRunning = "running"

    // DefaultNetworkMode is the mode of the container network
    DefaultNetworkMode = "default"
)

// these variable names can be injected by the docker engine
var (
    engineEnvVars   = []string{"https_proxy", "http_proxy", "no_proxy", "ftp_proxy"}
    builtinNetworks = []string{"default", "bridge", "host", "none", "container"}
)

// Container is responsible for creating docker containers
type Container struct {
    // the name of the container
    Name string `export:"name"`

    // the name of the image
    Image string `export:"image"`

    // the entrypoint into the container
    Entrypoint []string `export:"entrypoint"`

    // the command to run
    Command []string `export:"command"`

    // the working directory
    WorkingDir string `export:"workingdir"`

    // configured environment variables for the container
    Env []string `export:"env"`

    // additional ports to exposed in the container
    Expose []string `export:"expose"`

    // A list of links for the container in the form of container_name:alias
    Links []string `export:"links"`

    // ports to bind
    PortBindings []string `export:"portbindings"`

    // list of DNS servers the container is using
    DNS []string `export:"dns"`

    // volumes that have been bind-mounted
    Volumes []string `export:"volumes"`

    // containers from which volumes have been mounted
    VolumesFrom []string `export:"volumesfrom"`

    // if true, all ports have been published
    PublishAllPorts bool `export:"publishallports"`

    // the mode of the container network
    NetworkMode string `export:"networkmode"`

    // networks the container is connected to
    Networks []string `export:"networks"`

    // the status of the container.
    CStatus string `export:"status"`

    // Indicate whether the 'force' flag was set
    Force  bool `export:"force"`
    client docker.APIClient
}

// Check that a docker container with the specified configuration exists
func (c *Container) Check(context.Context, resource.Renderer) (resource.TaskStatus, error) {
    status := resource.NewStatus()
    container, err := c.client.FindContainer(c.Name)
    if err != nil {
        status.Level = resource.StatusFatal
        return status, err
    }

    if container != nil {
        status.AddDifference("name", strings.TrimPrefix(container.Name, "/"), c.Name, "")
        if c.Force {
            if diffErr := c.diffContainer(container, status); diffErr != nil {
                return nil, diffErr
            }
        }
    } else {
        status.AddDifference("name", "", c.Name, "<container-missing>")
    }

    status.RaiseLevelForDiffs()

    return status, nil
}

// Apply starts a docker container with the specified configuration
func (c *Container) Apply(context.Context) (resource.TaskStatus, error) {
    status := resource.NewStatus()
    volumes, binds := volumeConfigs(c.Volumes)
    config := &dc.Config{
        Image:        c.Image,
        WorkingDir:   c.WorkingDir,
        Env:          c.Env,
        ExposedPorts: toPortMap(c.Expose),
        Volumes:      volumes,
        Cmd:          c.Command,
        Entrypoint:   c.Entrypoint,
    }

    hostConfig := &dc.HostConfig{
        PublishAllPorts: c.PublishAllPorts,
        Links:           c.Links,
        DNS:             c.DNS,
        PortBindings:    toPortBindingMap(c.PortBindings),
        Binds:           binds,
        VolumesFrom:     c.VolumesFrom,
        NetworkMode:     c.NetworkMode,
    }

    opts := dc.CreateContainerOptions{
        Name:       c.Name,
        Config:     config,
        HostConfig: hostConfig,
    }

    container, err := c.client.CreateContainer(opts)
    if err != nil {
        return status, err
    }

    for _, name := range c.Networks {
        err = c.client.ConnectNetwork(name, container)
        if err != nil {
            return status, err
        }
    }

    if c.CStatus == "" || c.CStatus == containerStatusRunning {
        err = c.client.StartContainer(c.Name, container.ID)
        if err != nil {
            return status, err
        }
    }

    return status, nil
}

// SetClient injects a docker api client
func (c *Container) SetClient(client docker.APIClient) {
    c.client = client
}

func (c *Container) diffContainer(container *dc.Container, status *resource.Status) error {
    expectedStatus := strings.ToLower(c.CStatus)
    if expectedStatus == "" {
        expectedStatus = containerStatusRunning
    }
    status.AddDifference("status", strings.ToLower(container.State.Status), expectedStatus, "")

    if container.HostConfig != nil {
        status.AddDifference(
            "publish_all_ports",
            strconv.FormatBool(container.HostConfig.PublishAllPorts),
            strconv.FormatBool(c.PublishAllPorts),
            "false")
        status.AddDifference(
            "dns",
            strings.Join(container.HostConfig.DNS, ", "),
            strings.Join(c.DNS, ", "),
            "")
        status.AddDifference(
            "volumes_from",
            strings.Join(container.HostConfig.VolumesFrom, ", "),
            strings.Join(c.VolumesFrom, ", "),
            "")
        status.AddDifference(
            "network_mode",
            container.HostConfig.NetworkMode,
            c.NetworkMode,
            DefaultNetworkMode,
        )
    }

    image, err := c.client.FindImage(container.Image)
    if err != nil {
        status.Level = resource.StatusFatal
        return errors.Wrapf(err, "failed to find image %s for container %s", container.Image, container.Name)
    }
    if image == nil {
        return errors.New("backing image is unavailable")
    }

    var actual, expected string
    // if Cmd is empty, compare using the default from the container Image
    actual = strings.Join(container.Config.Cmd, " ")
    if len(c.Command) == 0 {
        expected = strings.Join(image.Config.Cmd, " ")
    } else {
        expected = strings.Join(c.Command, " ")
    }
    status.AddDifference("command", actual, expected, "")

    // if Entrypoint is empty, compare using the default from the container Image
    actual = strings.Join(container.Config.Entrypoint, " ")
    if len(c.Entrypoint) == 0 {
        expected = strings.Join(image.Config.Entrypoint, " ")
    } else {
        expected = strings.Join(c.Entrypoint, " ")
    }
    status.AddDifference("entrypoint", actual, expected, "")

    // if WorkingDir is empty, compare using the default from the container Image
    actual = container.Config.WorkingDir
    if c.WorkingDir == "" {
        expected = image.Config.WorkingDir
    } else {
        expected = c.WorkingDir
    }
    status.AddDifference("working_dir", actual, expected, "")

    // Env
    actual, expected = c.compareEnv(container, image)
    status.AddDifference("env", actual, expected, "")

    // Ports
    actual, expected = c.comparePortMappings(container)
    status.AddDifference("ports", actual, expected, "")

    // Expose
    actual, expected = c.compareExposedPorts(container, image)
    status.AddDifference("expose", actual, expected, "")

    // Links
    actual, expected = c.compareLinks(container)
    status.AddDifference("links", actual, expected, "")

    // Volumes
    actual, expected = c.compareVolumes(container, image)
    status.AddDifference("volumes", actual, expected, "")

    // Binds
    actual, expected = c.compareBinds(container)
    status.AddDifference("binds", actual, expected, "")

    // Networks
    actual, expected = c.compareNetworks(container)
    status.AddDifference("networks", actual, expected, "")

    // Image
    existingRepoTag := preferredRepoTag(c.Image, image)
    status.AddDifference("image", existingRepoTag, c.Image, "")

    return nil
}

func (c *Container) compareNetworks(container *dc.Container) (actual, expected string) {
    if container.NetworkSettings == nil {
        return "", ""
    }

    var containerNetworks []string
    for name := range container.NetworkSettings.Networks {
        var isBuiltin bool
        for _, builtin := range builtinNetworks {
            if strings.EqualFold(name, builtin) {
                isBuiltin = true
                break
            }
        }
        if !isBuiltin {
            containerNetworks = append(containerNetworks, name)
        }
    }
    sort.Strings(containerNetworks)

    expectedNetworks := make([]string, len(c.Networks))
    copy(expectedNetworks, c.Networks)
    sort.Strings(expectedNetworks)

    return strings.Join(containerNetworks, ", "), strings.Join(expectedNetworks, ", ")
}

func (c *Container) compareEnv(container *dc.Container, image *dc.Image) (actual, expected string) {
    varName := func(envvar string) string {
        return strings.ToLower(strings.Split(envvar, "=")[0])
    }

    varNameSet := func(env []string) mapset.Set {
        varnames := make([]string, len(env))
        for i, envvar := range env {
            varnames[i] = varName(envvar)
        }
        return toStringSet(varnames)
    }

    varSet := func(env []string, varNames mapset.Set) mapset.Set {
        set := mapset.NewSet()
        for _, envvar := range env {
            if varNames.Contains(varName(envvar)) {
                set.Add(envvar)
            }
        }
        return set
    }

    wantedVarNames := varNameSet(c.Env)                   // desired var names
    imageVarNames := varNameSet(image.Config.Env)         // var names defined in the image config
    engineVarNames := varNameSet(engineEnvVars)           // var names defined by the engine
    containerVarNames := varNameSet(container.Config.Env) // all var names defined in the running container

    // we want to ignore vars that are injected by the image config or the engine
    // unless they are explicitly set/overridden in the desired state
    compareVarNames := wantedVarNames.Union(containerVarNames.Difference(imageVarNames.Union(engineVarNames)))

    compareSet := varSet(container.Config.Env, compareVarNames)
    expectedSet := varSet(c.Env, compareVarNames)

    actual = joinStringSet(compareSet, " ")
    expected = joinStringSet(expectedSet, " ")

    return actual, expected
}

func (c *Container) compareExposedPorts(container *dc.Container, image *dc.Image) (actual, expected string) {
    toSet := func(portMap map[dc.Port]struct{}) mapset.Set {
        portSlice := make([]string, len(portMap))
        idx := 0
        for port := range portMap {
            portSlice[idx] = docker.NewPort(string(port)).String()
            idx++
        }
        return toStringSet(portSlice)
    }

    containerSet := toSet(container.Config.ExposedPorts)
    imageSet := toSet(image.Config.ExposedPorts)
    expectedSet := toSet(toPortMap(c.Expose)).Union(imageSet)

    actual = joinStringSet(containerSet, ", ")
    expected = joinStringSet(expectedSet, ", ")

    return actual, expected
}

func (c *Container) comparePortMappings(container *dc.Container) (actual, expected string) {
    toBindingsList := func(bindings map[dc.Port][]dc.PortBinding) []string {
        var containerBindings []string
        for port, pbindings := range bindings {
            cport := docker.NewPort(string(port)).String()
            for _, pbinding := range pbindings {
                ip := pbinding.HostIP
                hport := pbinding.HostPort
                if hport != "" {
                    hport = strings.Split(hport, "/")[0]
                }
                containerBindings = append(containerBindings, fmt.Sprintf("%s:%s:%s", ip, hport, cport))
            }
        }

        sort.Strings(containerBindings)
        return containerBindings
    }

    if container.HostConfig == nil {
        actual = ""
    } else {
        actual = strings.Join(toBindingsList(container.HostConfig.PortBindings), ", ")
    }

    expected = strings.Join(toBindingsList(toPortBindingMap(c.PortBindings)), ", ")

    return actual, expected
}

func (c *Container) compareLinks(container *dc.Container) (actual, expected string) {
    normalizeLink := func(link string) string {
        // internally links are stored as "/linkedcontainername:/containername/alias"
        parts := strings.Split(link, ":")
        if len(parts) == 1 {
            return strings.TrimPrefix(link, "/")
        }

        var name, alias string
        if strings.HasPrefix(parts[0], "/") {
            _, alias = path.Split(parts[1])
            name = parts[0][1:]
        } else {
            name = parts[0]
            alias = parts[1]
        }

        if strings.EqualFold(name, alias) {
            return name
        }

        return fmt.Sprintf("%s:%s", name, alias)
    }

    normalizedLinks := func(rawlinks []string) []string {
        links := make([]string, len(rawlinks))
        for i, link := range rawlinks {
            links[i] = normalizeLink(link)
        }
        sort.Strings(links)
        return links
    }

    if container.HostConfig == nil {
        actual = ""
    } else {
        actual = strings.Join(normalizedLinks(container.HostConfig.Links), ", ")
    }

    expected = strings.Join(normalizedLinks(c.Links), ", ")

    return actual, expected
}

func (c *Container) compareVolumes(container *dc.Container, image *dc.Image) (actual, expected string) {
    toSet := func(vols map[string]struct{}) mapset.Set {
        vollist := make([]string, len(vols))
        idx := 0
        for vol := range vols {
            vollist[idx] = vol
            idx++
        }
        return toStringSet(vollist)
    }

    containerSet := toSet(container.Config.Volumes)
    imageSet := toSet(image.Config.Volumes)

    volumes, _ := volumeConfigs(c.Volumes)
    expectedSet := toSet(volumes).Union(imageSet)

    actual = joinStringSet(containerSet, ", ")
    expected = joinStringSet(expectedSet, ", ")

    return actual, expected
}

func (c *Container) compareBinds(container *dc.Container) (actual, expected string) {
    toList := func(binds []string) []string {
        list := make([]string, len(binds))
        copy(list, binds)
        sort.Strings(list)
        return list
    }

    if container.HostConfig == nil {
        actual = ""
    } else {
        actual = strings.Join(toList(container.HostConfig.Binds), ", ")
    }

    _, binds := volumeConfigs(c.Volumes)
    expected = strings.Join(toList(binds), ", ")

    return actual, expected
}

func toPortBindingMap(portBindings []string) map[dc.Port][]dc.PortBinding {
    bindings := make(map[dc.Port][]dc.PortBinding)
    for _, mapping := range portBindings {
        parts := strings.Split(mapping, ":")
        partslen := len(parts)
        switch {
        case partslen == 1:
            cport := docker.NewPort(parts[0]).ToDockerClientPort()
            bindings[cport] = append(bindings[cport], dc.PortBinding{})
        case partslen == 2:
            hport := parts[0]
            cport := docker.NewPort(parts[1]).ToDockerClientPort()
            bindings[cport] = append(bindings[cport], dc.PortBinding{HostPort: hport})
        case partslen > 2:
            ip := parts[0]
            hport := parts[1]
            cport := docker.NewPort(parts[2]).ToDockerClientPort()
            bindings[cport] = append(bindings[cport], dc.PortBinding{HostPort: hport, HostIP: ip})
        }
    }
    return bindings
}

func toStringSet(strings []string) mapset.Set {
    set := mapset.NewSet()
    for _, val := range strings {
        set.Add(val)
    }
    return set
}

func joinStringSet(set mapset.Set, sep string) string {
    list := set.ToSlice()
    strlist := make([]string, len(list))
    for i, val := range list {
        strlist[i] = val.(string)
    }
    sort.Strings(strlist)
    return strings.Join(strlist, sep)
}

func toPortMap(portList []string) map[dc.Port]struct{} {
    portMap := make(map[dc.Port]struct{}, len(portList))
    for _, e := range portList {
        port := docker.NewPort(e).ToDockerClientPort()
        portMap[port] = struct{}{}
    }
    return portMap
}

func volumeConfigs(vols []string) (map[string]struct{}, []string) {
    volumes := make(map[string]struct{})
    binds := []string{}
    for _, vol := range vols {
        parts := strings.Split(vol, ":")
        partslen := len(parts)

        switch {
        case partslen == 1:
            volumes[parts[0]] = struct{}{}
        case partslen > 1:
            volume := parts[1]
            volumes[volume] = struct{}{}
            binds = append(binds, vol)
        }
    }

    return volumes, binds
}

func preferredRepoTag(want string, image *dc.Image) string {
    // look for the matching repo tag in case an image has multiple tags. if there
    // is no matching, return the first if it has one
    var existingRepoTag string
    if len(image.RepoTags) > 0 {
        for _, repoTag := range image.RepoTags {
            if strings.EqualFold(want, repoTag) {
                existingRepoTag = repoTag
                break
            }
        }

        if existingRepoTag == "" {
            existingRepoTag = image.RepoTags[0]
        }
    }

    return existingRepoTag
}