├── .gitignore ├── .mailmap ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTORS ├── LICENSE ├── Makefile ├── README.md ├── crane.yml ├── crane ├── accelerated_mount.go ├── build_parameters.go ├── cli.go ├── cli_test.go ├── config.go ├── config_test.go ├── container.go ├── container_test.go ├── containers.go ├── containers_test.go ├── crane.go ├── dependencies.go ├── dependencies_test.go ├── healthcheck_parameters.go ├── hooks.go ├── hooks_test.go ├── logging_parameters.go ├── network.go ├── network_parameters.go ├── target.go ├── target_test.go ├── unit_of_work.go ├── unit_of_work_test.go ├── version.go └── volume.go ├── docs ├── android-icon-144x144.png ├── android-icon-192x192.png ├── android-icon-36x36.png ├── android-icon-48x48.png ├── android-icon-72x72.png ├── android-icon-96x96.png ├── apple-icon-114x114.png ├── apple-icon-120x120.png ├── apple-icon-144x144.png ├── apple-icon-152x152.png ├── apple-icon-180x180.png ├── apple-icon-57x57.png ├── apple-icon-60x60.png ├── apple-icon-72x72.png ├── apple-icon-76x76.png ├── apple-icon-precomposed.png ├── apple-icon.png ├── browserconfig.xml ├── docs-accelerated-mounts.html ├── docs-advanced.html ├── docs-cli.html ├── docs-compatibility.html ├── docs-config.html ├── docs.html ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── favicon.ico ├── getting-started.html ├── images │ └── logo.png ├── index.html ├── installation.html ├── manifest.json ├── ms-icon-144x144.png ├── ms-icon-150x150.png ├── ms-icon-310x310.png ├── ms-icon-70x70.png └── robots.txt ├── download.sh ├── go.mod ├── go.sum ├── main.go └── release.sh /.gitignore: -------------------------------------------------------------------------------- 1 | crane_darwin_amd64 2 | crane_darwin_arm64 3 | crane_linux_amd64 4 | crane_linux_arm 5 | crane_linux_arm64 6 | crane_windows_amd64.exe 7 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Michael Sauter Michael Sauter 2 | Michael Sauter Michael Sauter 3 | Brice Jaglin Brice Jaglin 4 | Adrian Hurtado adrianhurt 5 | Jesper Thomschutz Jesper Thomschutz 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - "1.11.x" 4 | before_install: 5 | - go get golang.org/x/tools/cmd/goimports 6 | script: env GO111MODULE=on make test -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Michael Sauter 2 | Brice Jaglin 3 | rich 4 | Dreamcat4 5 | Mikhail Grachev 6 | Michal Gebauer 7 | Michał Rączka 8 | Tomotaka SUWA 9 | Adrian Hurtado 10 | inthroxify 11 | Bradley Cicenas 12 | Mathew Davies 13 | David Lefever 14 | Chris Rebert 15 | Pierre DAL-PRA 16 | Scott M. Likens 17 | Thibault Vigouroux 18 | Travis Cline 19 | c-nv-s 20 | eggtree 21 | gissehel 22 | Inthroxify <13877157+inthroxify@users.noreply.github.com> 23 | Eliran Bivas 24 | Jesper Thomschutz 25 | Jesper Thomschütz 26 | Joshua Sierles 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Michael Sauter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: imports 2 | @(go list ./... | grep -v "vendor/" | xargs -n1 go test -v -cover) 3 | 4 | imports: 5 | @(goimports -w crane) 6 | 7 | fmt: 8 | @(gofmt -w crane) 9 | 10 | build: build-linux build-linux-arm build-linux-arm64 build-darwin build-darwin-arm64 build-windows 11 | 12 | build-linux: imports 13 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o crane_linux_amd64 -v github.com/michaelsauter/crane/v3 14 | 15 | build-linux-arm: imports 16 | GOOS=linux GOARCH=arm CGO_ENABLED=0 go build -o crane_linux_arm -v github.com/michaelsauter/crane/v3 17 | 18 | build-linux-arm64: imports 19 | GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o crane_linux_arm64 -v github.com/michaelsauter/crane/v3 20 | 21 | build-darwin: imports 22 | GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o crane_darwin_amd64 -v github.com/michaelsauter/crane/v3 23 | 24 | build-darwin-arm64: imports 25 | GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o crane_darwin_arm64 -v github.com/michaelsauter/crane/v3 26 | 27 | build-windows: imports 28 | GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o crane_windows_amd64.exe -v github.com/michaelsauter/crane/v3 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crane 2 | Lift containers with ease - [michaelsauter.github.io/crane](https://michaelsauter.github.io/crane/) 3 | 4 | 5 | ## Overview 6 | Crane is a Docker orchestration tool similar to Docker Compose with extra 7 | features and (arguably) smarter behaviour. It works by reading in some 8 | configuration (JSON or YAML) which describes how to run containers. Crane is 9 | ideally suited for development environments or continuous integration. 10 | 11 | ## Features 12 | 13 | * Extensive support of Docker run flags 14 | * Simple configuration with 1:1 mapping to Docker run flags 15 | * `docker-compose` compatible 16 | * **ultra-fast bind-mounts via Unison on Mac** 17 | * Shortcut commands 18 | * Flexible ways to target containers (through groups and CLI flags to exclude/limit) 19 | * Smart detach / attach behaviour 20 | * Verbose output which shows exact Docker commands 21 | * Hooks 22 | * ... and much more! 23 | 24 | ## Documentation & Usage 25 | 26 | Please see [michaelsauter.github.io/crane/docs.html](https://michaelsauter.github.io/crane/docs.html). 27 | 28 | ## Installation 29 | 30 | The latest release is 3.6.1 and requires Docker >= 1.13. 31 | Please have a look at the [changelog](https://github.com/michaelsauter/crane/blob/master/CHANGELOG.md) when upgrading. 32 | 33 | ``` 34 | bash -c "`curl -sL https://raw.githubusercontent.com/michaelsauter/crane/v3.6.1/download.sh`" && \ 35 | mv crane /usr/local/bin/crane 36 | ``` 37 | 38 | --- 39 | 40 | Copyright © 2013-2020 Michael Sauter. See the LICENSE file for details. 41 | 42 | --- 43 | 44 | [![GoDoc](https://godoc.org/github.com/michaelsauter/crane?status.png)](https://godoc.org/github.com/michaelsauter/crane) 45 | [![Build Status](https://travis-ci.org/michaelsauter/crane.svg?branch=master)](https://travis-ci.org/michaelsauter/crane) 46 | -------------------------------------------------------------------------------- /crane.yml: -------------------------------------------------------------------------------- 1 | services: 2 | crane: 3 | image: michaelsauter/golang:1.11 4 | rm: true 5 | interactive: true 6 | tty: true 7 | volume: [".:/crane"] 8 | workdir: /crane 9 | share-ssh-socket: true 10 | cmd: ["bash"] 11 | 12 | commands: 13 | test: run crane make test 14 | build: run crane make build 15 | build-darwin: run crane make build-darwin 16 | gofmt: run crane gofmt -w crane 17 | 18 | accelerated-mounts: 19 | crane: 20 | uid: 1000 21 | gid: 1000 22 | -------------------------------------------------------------------------------- /crane/accelerated_mount.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "os" 7 | "runtime" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | type AcceleratedMount interface { 13 | Run() 14 | Reset() 15 | Logs(follow bool) 16 | VolumeArg() string 17 | Volume() string 18 | } 19 | 20 | type acceleratedMount struct { 21 | RawVolume string 22 | RawIgnore string `json:"ignore" yaml:"ignore"` 23 | RawFlags []string `json:"flags" yaml:"flags"` 24 | Uid int `json:"uid" yaml:"uid"` 25 | Gid int `json:"gid" yaml:"gid"` 26 | configPath string 27 | volume string 28 | _digest string 29 | } 30 | 31 | func (am *acceleratedMount) Volume() string { 32 | if am.volume == "" { 33 | am.volume = expandEnv(am.RawVolume) 34 | } 35 | return am.volume 36 | } 37 | 38 | func (am *acceleratedMount) Run() { 39 | if !am.running() { 40 | am.ensureDataVolume() 41 | am.initialSync() 42 | am.continuousSync() 43 | } 44 | } 45 | 46 | func (am *acceleratedMount) VolumeArg() string { 47 | return am.dataVolumeName() + ":" + am.bindMountContainerPart() 48 | } 49 | 50 | func (am *acceleratedMount) Reset() { 51 | args := []string{"rm", "-f", am.syncContainerName()} 52 | executeCommand("docker", args, os.Stdout, os.Stderr) 53 | args = []string{"volume", "rm", am.dataVolumeName()} 54 | executeCommand("docker", args, os.Stdout, os.Stderr) 55 | } 56 | 57 | func (am *acceleratedMount) Logs(follow bool) { 58 | args := []string{"logs"} 59 | if follow { 60 | args = append(args, "-f") 61 | } 62 | args = append(args, am.syncContainerName()) 63 | executeCommand("docker", args, os.Stdout, os.Stderr) 64 | } 65 | 66 | func (am *acceleratedMount) running() bool { 67 | return am.syncContainerExists() && inspectBool(am.syncContainerName(), "{{.State.Running}}") 68 | } 69 | 70 | func (am *acceleratedMount) ensureDataVolume() { 71 | if !am.dataVolumeExists() { 72 | printInfof("Creating volume %s ...\n", am.dataVolumeName()) 73 | args := []string{ 74 | "volume", 75 | "create", 76 | "--name", am.dataVolumeName(), 77 | "--label", "com.crane-orchestration.accelerated-mount=" + am.Volume(), 78 | } 79 | executeHiddenCommand("docker", args) 80 | } 81 | } 82 | 83 | func (am *acceleratedMount) initialSync() { 84 | dockerArgs := []string{"run", "--rm"} 85 | dockerArgs = append(dockerArgs, "-e", "UNISON_CHOWN=1") 86 | dockerArgs = append(dockerArgs, am.syncContainerArgs()...) 87 | dockerArgs = append(dockerArgs, am.initialFlags()...) 88 | dockerArgs = append(dockerArgs, "/bind-mount", "/data-volume") 89 | printInfof("Doing initial sync for %s ... this might take a while\n", am.bindMountHostPart()) 90 | executeHiddenCommand("docker", dockerArgs) 91 | } 92 | 93 | func (am *acceleratedMount) continuousSync() { 94 | if am.syncContainerExists() { 95 | dockerArgs := []string{"start", am.syncContainerName()} 96 | printInfof("Starting sync for %s via %s ...\n", am.bindMountHostPart(), am.syncContainerName()) 97 | executeCommand("docker", dockerArgs, nil, nil) 98 | } else { 99 | dockerArgs := []string{"run", "--name", am.syncContainerName(), "-d"} 100 | dockerArgs = append(dockerArgs, am.syncContainerArgs()...) 101 | dockerArgs = append(dockerArgs, am.continuousFlags()...) 102 | dockerArgs = append(dockerArgs, "/bind-mount", "/data-volume") 103 | printInfof("Starting sync for %s via %s ...\n", am.bindMountHostPart(), am.syncContainerName()) 104 | executeCommand("docker", dockerArgs, nil, nil) 105 | } 106 | } 107 | 108 | func (am *acceleratedMount) dataVolumeExists() bool { 109 | args := []string{"volume", "inspect", am.dataVolumeName()} 110 | _, err := commandOutput("docker", args) 111 | return err == nil 112 | } 113 | 114 | // If flags is given in the config, its value is used. 115 | // Otherwise, we check if ingore is configured, and use 116 | // its value for the -ignore flag. Otherwise the default 117 | // flags are sent to Unison. 118 | func (am *acceleratedMount) flags() []string { 119 | if len(am.RawFlags) > 0 { 120 | f := []string{} 121 | for _, rawFlag := range am.RawFlags { 122 | f = append(f, expandEnv(rawFlag)) 123 | } 124 | return f 125 | } 126 | 127 | ignore := "Name {.git}" 128 | if len(am.RawIgnore) > 0 { 129 | ignore = expandEnv(am.RawIgnore) 130 | } 131 | ignoreFlag := fmt.Sprintf("-ignore='%s'", ignore) 132 | 133 | return []string{"-auto", "-batch", ignoreFlag, "-contactquietly", "-confirmbigdel=false", "-prefer=newer"} 134 | } 135 | 136 | func (am *acceleratedMount) initialFlags() []string { 137 | return append(am.flags(), "-ignorearchives") 138 | } 139 | 140 | func (am *acceleratedMount) continuousFlags() []string { 141 | return append(am.flags(), "-repeat=watch") 142 | } 143 | 144 | func (am *acceleratedMount) syncContainerExists() bool { 145 | return containerID(am.syncContainerName()) != "" 146 | } 147 | 148 | func (am *acceleratedMount) syncContainerArgs() []string { 149 | return []string{ 150 | "-v", am.bindMountHostPart() + ":/bind-mount", 151 | "-v", am.dataVolumeName() + ":/data-volume", 152 | "-e", "UNISON_UID=" + strconv.Itoa(am.Uid), 153 | "-e", "UNISON_GID=" + strconv.Itoa(am.Gid), 154 | "--label", "com.crane-orchestration.accelerated-mount=" + am.Volume(), 155 | am.image(), 156 | } 157 | } 158 | 159 | func (am *acceleratedMount) dataVolumeName() string { 160 | return "crane_vol_" + am.digest() 161 | } 162 | 163 | func (am *acceleratedMount) syncContainerName() string { 164 | return "crane_sync_" + am.digest() 165 | } 166 | 167 | func (am *acceleratedMount) digest() string { 168 | if am._digest == "" { 169 | syncIdentifierParts := []string{ 170 | am.configPath, 171 | am.Volume(), 172 | am.image(), 173 | strings.Join(am.flags(), " "), 174 | strconv.Itoa(am.Uid), 175 | strconv.Itoa(am.Gid), 176 | } 177 | syncIdentifier := []byte(strings.Join(syncIdentifierParts, ":")) 178 | am._digest = fmt.Sprintf("%x", md5.Sum(syncIdentifier)) 179 | } 180 | return am._digest 181 | } 182 | 183 | func (am *acceleratedMount) image() string { 184 | return "michaelsauter/crane-sync:3.2.0" 185 | } 186 | 187 | func (am *acceleratedMount) bindMountHostPart() string { 188 | parts := strings.Split(actualVolumeArg(am.Volume()), ":") 189 | return parts[0] 190 | } 191 | 192 | func (am *acceleratedMount) bindMountContainerPart() string { 193 | parts := strings.Split(actualVolumeArg(am.Volume()), ":") 194 | return parts[1] 195 | } 196 | 197 | func accelerationEnabled() bool { 198 | return runtime.GOOS == "darwin" || runtime.GOOS == "windows" 199 | } 200 | -------------------------------------------------------------------------------- /crane/build_parameters.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | type BuildParameters struct { 4 | RawContext string `json:"context" yaml:"context"` 5 | RawFile string `json:"file" yaml:"file"` 6 | RawDockerfile string `json:"dockerfile" yaml:"dockerfile"` 7 | RawBuildArgs interface{} `json:"build-arg" yaml:"build-arg"` 8 | RawArgs interface{} `json:"args" yaml:"args"` 9 | } 10 | 11 | func (b BuildParameters) Context() string { 12 | return expandEnv(b.RawContext) 13 | } 14 | 15 | func (b BuildParameters) File() string { 16 | if len(b.RawFile) > 0 { 17 | return expandEnv(b.RawFile) 18 | } 19 | return expandEnv(b.RawDockerfile) 20 | } 21 | 22 | func (b BuildParameters) BuildArgs() []string { 23 | buildArgs := sliceOrMap2ExpandedSlice(b.RawBuildArgs) 24 | if len(buildArgs) == 0 { 25 | return sliceOrMap2ExpandedSlice(b.RawArgs) 26 | } 27 | return buildArgs 28 | } 29 | -------------------------------------------------------------------------------- /crane/cli_test.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestAllowedContainers(t *testing.T) { 11 | rawContainerMap := map[string]Container{ 12 | "a": &container{}, 13 | "b": &container{}, 14 | "c": &container{}, 15 | } 16 | groups := map[string][]string{ 17 | "group": []string{"a", "b"}, 18 | } 19 | cfg = &config{containerMap: rawContainerMap, groups: groups} 20 | 21 | examples := []struct { 22 | excluded []string 23 | only string 24 | expected []string 25 | }{ 26 | { 27 | excluded: []string{}, 28 | only: "", 29 | expected: []string{"a", "b", "c"}, 30 | }, 31 | { 32 | excluded: []string{"a"}, 33 | only: "", 34 | expected: []string{"b", "c"}, 35 | }, 36 | { 37 | excluded: []string{}, 38 | only: "a", 39 | expected: []string{"a"}, 40 | }, 41 | { 42 | excluded: []string{}, 43 | only: "group", 44 | expected: []string{"a", "b"}, 45 | }, 46 | { 47 | excluded: []string{"b"}, 48 | only: "group", 49 | expected: []string{"a"}, 50 | }, 51 | } 52 | 53 | for _, example := range examples { 54 | containers := allowedContainers(example.excluded, example.only) 55 | sort.Strings(containers) 56 | assert.Equal(t, example.expected, containers) 57 | } 58 | 59 | // with a default group 60 | rawContainerMap = map[string]Container{ 61 | "a": &container{}, 62 | "b": &container{}, 63 | "c": &container{}, 64 | } 65 | groups = map[string][]string{ 66 | "default": []string{"a", "b"}, 67 | } 68 | cfg = &config{containerMap: rawContainerMap, groups: groups} 69 | containers := allowedContainers([]string{}, "") 70 | sort.Strings(containers) 71 | assert.Equal(t, []string{"a", "b", "c"}, containers) 72 | } 73 | -------------------------------------------------------------------------------- /crane/config.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/imdario/mergo" 17 | yaml "gopkg.in/yaml.v2" 18 | ) 19 | 20 | type Config interface { 21 | DependencyMap() map[string]*Dependencies 22 | ContainersForReference(reference string) (result []string) 23 | Path() string 24 | UniqueID() string 25 | Prefix() string 26 | Tag() string 27 | NetworkNames() []string 28 | VolumeNames() []string 29 | Cmds() map[string][]string 30 | AcceleratedMountNames() []string 31 | Network(name string) Network 32 | Volume(name string) Volume 33 | Cmd(name string) []string 34 | AcceleratedMount(volume string) AcceleratedMount 35 | ContainerMap() ContainerMap 36 | Container(name string) Container 37 | ContainerInfo(name string) ContainerInfo 38 | } 39 | 40 | type config struct { 41 | RawPrefix interface{} `json:"prefix" yaml:"prefix"` 42 | RawContainers map[string]*container `json:"services" yaml:"services"` 43 | RawGroups map[string][]string `json:"groups" yaml:"groups"` 44 | RawHooks map[string]hooks `json:"hooks" yaml:"hooks"` 45 | RawNetworks map[string]*network `json:"networks" yaml:"networks"` 46 | RawVolumes map[string]*volume `json:"volumes" yaml:"volumes"` 47 | RawCmds map[string]interface{} `json:"commands" yaml:"commands"` 48 | RawAcceleratedMounts map[string]*acceleratedMount `json:"accelerated-mounts" yaml:"accelerated-mounts"` 49 | RawMacSyncs map[string]*acceleratedMount `json:"mac-syncs" yaml:"mac-syncs"` 50 | containerMap ContainerMap 51 | networkMap NetworkMap 52 | volumeMap VolumeMap 53 | acceleratedMountMap AcceleratedMountMap 54 | groups map[string][]string 55 | cmds map[string][]string 56 | path string 57 | prefix string 58 | tag string 59 | uniqueID string 60 | } 61 | 62 | // ContainerMap maps the container name 63 | // to its configuration 64 | type ContainerMap map[string]Container 65 | 66 | type NetworkMap map[string]Network 67 | 68 | type VolumeMap map[string]Volume 69 | 70 | type AcceleratedMountMap map[string]AcceleratedMount 71 | 72 | // readFile will read the config file 73 | // and return the created config. 74 | func readFile(filename string) *config { 75 | verboseMsg("Reading configuration " + filename) 76 | data, err := ioutil.ReadFile(filename) 77 | if err != nil { 78 | panic(StatusError{err, 74}) 79 | } 80 | 81 | ext := filepath.Ext(filename) 82 | return unmarshal(data, ext) 83 | } 84 | 85 | // displaySyntaxError will display more information 86 | // such as line and error type given an error and 87 | // the data that was unmarshalled. 88 | // Thanks to https://github.com/markpeek/packer/commit/5bf33a0e91b2318a40c42e9bf855dcc8dd4cdec5 89 | func displaySyntaxError(data []byte, syntaxError error) (err error) { 90 | syntax, ok := syntaxError.(*json.SyntaxError) 91 | if !ok { 92 | err = syntaxError 93 | return 94 | } 95 | newline := []byte{'\x0a'} 96 | space := []byte{' '} 97 | 98 | start, end := bytes.LastIndex(data[:syntax.Offset], newline)+1, len(data) 99 | if idx := bytes.Index(data[start:], newline); idx >= 0 { 100 | end = start + idx 101 | } 102 | 103 | line, pos := bytes.Count(data[:start], newline)+1, int(syntax.Offset)-start-1 104 | 105 | err = fmt.Errorf("\nError in line %d: %s \n%s\n%s^", line, syntaxError, data[start:end], bytes.Repeat(space, pos)) 106 | return 107 | } 108 | 109 | // unmarshal converts either JSON 110 | // or YAML into a config object. 111 | func unmarshal(data []byte, ext string) *config { 112 | var config *config 113 | var err error 114 | if ext == ".json" { 115 | err = json.Unmarshal(data, &config) 116 | } else if ext == ".yml" || ext == ".yaml" { 117 | err = yaml.Unmarshal(data, &config) 118 | } else { 119 | panic(StatusError{errors.New("Unrecognized file extension"), 65}) 120 | } 121 | if err != nil { 122 | err = displaySyntaxError(data, err) 123 | panic(StatusError{err, 65}) 124 | } 125 | return config 126 | } 127 | 128 | // NewConfig retus a new config based on given 129 | // location. 130 | // Containers will be ordered so that they can be 131 | // brought up and down with Docker. 132 | func NewConfig(files []string, prefix string, tag string) Config { 133 | var config *config 134 | // Files can be given colon-separated 135 | expandedFiles := []string{} 136 | for _, f := range files { 137 | expandedFiles = append(expandedFiles, strings.Split(f, ":")...) 138 | } 139 | configPath := findConfigPath(expandedFiles) 140 | config = readConfig(configPath, expandedFiles) 141 | config.path = configPath 142 | config.initialize(prefix) 143 | config.validate() 144 | config.tag = tag 145 | milliseconds := time.Now().UnixNano() / 1000000 146 | config.uniqueID = strconv.FormatInt(milliseconds, 10) 147 | return config 148 | } 149 | 150 | func readConfig(configPath string, files []string) *config { 151 | var config *config 152 | 153 | for _, f := range files { 154 | filename := filepath.Base(f) 155 | absFile := filepath.Join(configPath, filename) 156 | if _, err := os.Stat(absFile); err == nil { 157 | fileConfig := readFile(absFile) 158 | if config == nil { 159 | config = fileConfig 160 | } else { 161 | mergo.Merge(config, fileConfig) 162 | } 163 | } else if !includes(defaultFiles, filename) { 164 | panic(StatusError{fmt.Errorf("Configuration file %v was not found!", filename), 78}) 165 | } 166 | } 167 | 168 | return config 169 | } 170 | 171 | func findConfigPath(files []string) string { 172 | // If the first of the locations array is specified as an absolute 173 | // path, we use its directory as the config path. 174 | if filepath.IsAbs(files[0]) { 175 | return filepath.Dir(files[0]) 176 | } 177 | 178 | // Otherwise, we traverse directories upwards, until we find a 179 | // directory which has one of the locations, then use that 180 | // directory as the config path. 181 | configPath, _ := os.Getwd() 182 | for { 183 | for _, f := range files { 184 | filename := filepath.Join(configPath, f) 185 | if _, err := os.Stat(filename); err == nil { 186 | return configPath 187 | } 188 | } 189 | // Loop only if we haven't yet reached the root 190 | if parentPath := filepath.Dir(configPath); len(parentPath) != len(configPath) { 191 | configPath = parentPath 192 | } else { 193 | break 194 | } 195 | } 196 | 197 | panic(StatusError{fmt.Errorf("No config files found for: %v", files), 78}) 198 | } 199 | 200 | // Return path of config file 201 | func (c *config) Path() string { 202 | return c.path 203 | } 204 | 205 | func (c *config) UniqueID() string { 206 | return c.uniqueID 207 | } 208 | 209 | func (c *config) Prefix() string { 210 | return c.prefix 211 | } 212 | 213 | func (c *config) Tag() string { 214 | return c.tag 215 | } 216 | 217 | func (c *config) ContainerMap() ContainerMap { 218 | return c.containerMap 219 | } 220 | 221 | func (c *config) Container(name string) Container { 222 | return c.containerMap[name] 223 | } 224 | 225 | func (c *config) ContainerInfo(name string) ContainerInfo { 226 | return c.Container(name) 227 | } 228 | 229 | func (c *config) NetworkNames() []string { 230 | networks := []string{} 231 | for name, _ := range c.networkMap { 232 | networks = append(networks, name) 233 | } 234 | sort.Strings(networks) 235 | return networks 236 | } 237 | 238 | func (c *config) VolumeNames() []string { 239 | volumes := []string{} 240 | for name, _ := range c.volumeMap { 241 | volumes = append(volumes, name) 242 | } 243 | sort.Strings(volumes) 244 | return volumes 245 | } 246 | 247 | func (c *config) Cmds() map[string][]string { 248 | return c.cmds 249 | } 250 | 251 | func (c *config) AcceleratedMountNames() []string { 252 | acceleratedMounts := []string{} 253 | for name, _ := range c.acceleratedMountMap { 254 | acceleratedMounts = append(acceleratedMounts, name) 255 | } 256 | sort.Strings(acceleratedMounts) 257 | return acceleratedMounts 258 | } 259 | 260 | func (c *config) Network(name string) Network { 261 | return c.networkMap[name] 262 | } 263 | 264 | func (c *config) Volume(name string) Volume { 265 | return c.volumeMap[name] 266 | } 267 | 268 | func (c *config) AcceleratedMount(name string) AcceleratedMount { 269 | return c.acceleratedMountMap[name] 270 | } 271 | 272 | func (c *config) Cmd(name string) []string { 273 | return c.cmds[name] 274 | } 275 | 276 | // Load configuration into the internal structs from the raw, parsed ones 277 | func (c *config) initialize(prefixFlag string) { 278 | // Local container map to query by expanded name 279 | containerMap := make(map[string]*container) 280 | for rawName, container := range c.RawContainers { 281 | container.RawName = rawName 282 | containerMap[container.Name()] = container 283 | } 284 | // Local hooks map to query by expanded name 285 | hooksMap := make(map[string]hooks) 286 | for hooksRawName, hooks := range c.RawHooks { 287 | hooksMap[expandEnv(hooksRawName)] = hooks 288 | } 289 | // Groups 290 | c.groups = make(map[string][]string) 291 | for groupRawName, rawNames := range c.RawGroups { 292 | groupName := expandEnv(groupRawName) 293 | for _, rawName := range rawNames { 294 | c.groups[groupName] = append(c.groups[groupName], expandEnv(rawName)) 295 | } 296 | if hooks, ok := hooksMap[groupName]; ok { 297 | // attach group-defined hooks to the group containers 298 | for _, name := range c.groups[groupName] { 299 | if overriden := containerMap[name].hooks.CopyFrom(hooks); overriden { 300 | panic(StatusError{fmt.Errorf("Multiple conflicting hooks inherited from groups for container `%s`", name), 64}) 301 | } 302 | } 303 | } 304 | } 305 | // Cmds 306 | c.cmds = make(map[string][]string) 307 | for cmdRawName, rawCmd := range c.RawCmds { 308 | cmdName := expandEnv(cmdRawName) 309 | c.cmds[cmdName] = stringSlice(rawCmd) 310 | } 311 | // Container map 312 | c.containerMap = make(map[string]Container) 313 | for name, container := range containerMap { 314 | if hooks, ok := hooksMap[name]; ok { 315 | // attach container-defined hooks, overriding potential group-inherited hooks 316 | container.hooks.CopyFrom(hooks) 317 | } 318 | c.containerMap[name] = container 319 | } 320 | 321 | c.determinePrefix(prefixFlag) 322 | c.setNetworkMap() 323 | c.setVolumeMap() 324 | c.setAcceleratedMountMap() 325 | } 326 | 327 | func (c *config) setNetworkMap() { 328 | c.networkMap = make(map[string]Network) 329 | for rawName, net := range c.RawNetworks { 330 | if net == nil { 331 | net = &network{} 332 | } 333 | net.RawName = rawName 334 | c.networkMap[net.Name()] = net 335 | } 336 | // Add default network if possible and not defined yet 337 | if _, ok := c.networkMap["default"]; ok { 338 | if len(c.prefix) == 0 { 339 | panic(StatusError{fmt.Errorf("You must configure a prefix to use the default network"), 65}) 340 | } 341 | } else { 342 | if len(c.prefix) > 0 { 343 | c.networkMap["default"] = &network{RawName: "default"} 344 | } else { 345 | verboseMsg("No default network will be setup as prefix is disabled.") 346 | } 347 | } 348 | } 349 | 350 | func (c *config) setVolumeMap() { 351 | c.volumeMap = make(map[string]Volume) 352 | for rawName, vol := range c.RawVolumes { 353 | if vol == nil { 354 | vol = &volume{} 355 | } 356 | vol.RawName = rawName 357 | c.volumeMap[vol.Name()] = vol 358 | } 359 | } 360 | 361 | // accelerated mounts can either be: 362 | // * a service (which is expanded to all configured bind-mounts) or 363 | // * a single bind-mount 364 | func (c *config) setAcceleratedMountMap() { 365 | c.acceleratedMountMap = make(map[string]AcceleratedMount) 366 | for rawVolume, am := range c.RawAcceleratedMounts { 367 | container := c.Container(rawVolume) 368 | if container != nil { 369 | for _, bm := range container.BindMounts(c.VolumeNames()) { 370 | if am == nil { 371 | am = &acceleratedMount{} 372 | } 373 | am.RawVolume = bm 374 | am.configPath = c.path 375 | c.acceleratedMountMap[am.Volume()] = am 376 | } 377 | } else { 378 | if am == nil { 379 | am = &acceleratedMount{} 380 | } 381 | am.RawVolume = rawVolume 382 | am.configPath = c.path 383 | c.acceleratedMountMap[am.Volume()] = am 384 | } 385 | } 386 | // legacy support for mac-syncs configuration 387 | for rawVolume, am := range c.RawMacSyncs { 388 | if am == nil { 389 | am = &acceleratedMount{} 390 | } 391 | am.RawVolume = rawVolume 392 | am.configPath = c.path 393 | c.acceleratedMountMap[am.Volume()] = am 394 | } 395 | } 396 | 397 | // CLI > Config > Default 398 | func (c *config) determinePrefix(prefixFlag string) { 399 | // CLI takes precedence over config 400 | if len(prefixFlag) > 0 { 401 | c.prefix = prefixFlag 402 | return 403 | } 404 | // If prefix is not configured, it is equal to prefix: true 405 | if c.RawPrefix == nil { 406 | c.RawPrefix = true 407 | } 408 | // Use configured prefix: 409 | // true -> folder name 410 | // false -> no prefix 411 | // string -> use as-is 412 | switch concretePrefix := c.RawPrefix.(type) { 413 | case bool: 414 | if concretePrefix { 415 | c.prefix = filepath.Base(c.path) + "_" 416 | } else { 417 | c.prefix = "" 418 | } 419 | case string: 420 | c.prefix = expandEnv(concretePrefix) 421 | default: 422 | panic(StatusError{fmt.Errorf("prefix must be either string or boolean, got %s", c.RawPrefix), 65}) 423 | } 424 | } 425 | 426 | func (c *config) validate() { 427 | for name, container := range c.RawContainers { 428 | if len(container.RawImage) == 0 && container.RawBuild == (BuildParameters{}) { 429 | panic(StatusError{fmt.Errorf("Neither image or build specified for `%s`", name), 64}) 430 | } 431 | } 432 | } 433 | 434 | // DependencyMap returns a map of containers to their dependencies. 435 | func (c *config) DependencyMap() map[string]*Dependencies { 436 | dependencyMap := make(map[string]*Dependencies) 437 | for _, container := range c.containerMap { 438 | if includes(allowed, container.Name()) { 439 | dependencyMap[container.Name()] = container.Dependencies() 440 | } 441 | } 442 | return dependencyMap 443 | } 444 | 445 | // ContainersForReference receives a reference and determines which 446 | // containers of the map that resolves to. 447 | func (c *config) ContainersForReference(reference string) (result []string) { 448 | containers := []string{} 449 | if len(reference) == 0 { 450 | // reference not given 451 | var defaultGroup []string 452 | for group, containers := range c.groups { 453 | if group == "default" { 454 | defaultGroup = containers 455 | break 456 | } 457 | } 458 | if defaultGroup != nil { 459 | // If default group exists, return its containers 460 | containers = defaultGroup 461 | } else { 462 | // Otherwise, return all containers 463 | for name := range c.containerMap { 464 | containers = append(containers, name) 465 | } 466 | } 467 | } else { 468 | // reference given 469 | reference = expandEnv(reference) 470 | // Select reference from listed groups 471 | for group, groupContainers := range c.groups { 472 | if group == reference { 473 | containers = append(containers, groupContainers...) 474 | break 475 | } 476 | } 477 | if len(containers) == 0 { 478 | // The reference might just be one container 479 | for name := range c.containerMap { 480 | if name == reference { 481 | containers = append(containers, reference) 482 | break 483 | } 484 | } 485 | } 486 | if len(containers) == 0 { 487 | // reference was not found anywhere 488 | panic(StatusError{fmt.Errorf("No group or container matching `%s`", reference), 64}) 489 | } 490 | } 491 | // ensure all container references exist 492 | for _, container := range containers { 493 | containerDeclared := false 494 | for name := range c.containerMap { 495 | if container == name { 496 | containerDeclared = true 497 | break 498 | } 499 | } 500 | if !containerDeclared { 501 | panic(StatusError{fmt.Errorf("Invalid container reference `%s`", container), 64}) 502 | } 503 | if !includes(result, container) { 504 | result = append(result, container) 505 | } 506 | } 507 | return 508 | } 509 | -------------------------------------------------------------------------------- /crane/config_test.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // Create a map of stubbed containers out of the provided set 11 | func NewStubbedContainerMap(exists bool, containers ...Container) ContainerMap { 12 | containerMap := make(map[string]Container) 13 | for _, container := range containers { 14 | containerMap[container.Name()] = &StubbedContainer{container, exists} 15 | } 16 | return containerMap 17 | } 18 | 19 | type StubbedContainer struct { 20 | Container 21 | exists bool 22 | } 23 | 24 | func (stubbedContainer *StubbedContainer) Exists() bool { 25 | return stubbedContainer.exists 26 | } 27 | 28 | func TestUnmarshal(t *testing.T) { 29 | var actual *config 30 | var networks map[string]NetworkParameters 31 | json := []byte( 32 | `{ 33 | "services": { 34 | "apache": { 35 | "build": { 36 | "dockerfile": "apache" 37 | }, 38 | "image": "michaelsauter/apache", 39 | "volumes-from": ["crane_app"], 40 | "publish": ["80:80"], 41 | "networks": { 42 | "foo": { 43 | "alias": [ 44 | "bar", 45 | "baz" 46 | ], 47 | "ip": "192.168.0.1" 48 | } 49 | }, 50 | "env": { 51 | "foo": 1234, 52 | "4567": "bar", 53 | "true": false 54 | }, 55 | "label": [ 56 | "foo=1234", 57 | "4567=bar", 58 | "true=false" 59 | ], 60 | "link": ["crane_mysql:db", "crane_memcached:cache"], 61 | "detach": true 62 | } 63 | }, 64 | "groups": { 65 | "default": [ 66 | "apache" 67 | ] 68 | }, 69 | "hooks": { 70 | "apache": { 71 | "post-stop": "echo apache container stopped!\n" 72 | }, 73 | "default": { 74 | "pre-start": "echo start...", 75 | "post-start": "echo start done!\n" 76 | } 77 | }, 78 | "networks": { 79 | "foo": {} 80 | }, 81 | "volumes": { 82 | "bar": {} 83 | } 84 | } 85 | `) 86 | actual = unmarshal(json, ".json") 87 | assert.Len(t, actual.RawContainers, 1) 88 | assert.Len(t, actual.RawContainers["apache"].Env(), 3) 89 | assert.Len(t, actual.RawContainers["apache"].Label(), 3) 90 | assert.Len(t, actual.RawContainers["apache"].Link(), 2) 91 | networks = actual.RawContainers["apache"].Networks() 92 | assert.Len(t, networks, 1) 93 | assert.Equal(t, []string{"bar", "baz"}, networks["foo"].Alias("apache")) 94 | assert.Equal(t, "192.168.0.1", networks["foo"].Ip()) 95 | assert.Equal(t, []string{"apache"}, networks["default"].Alias("apache")) 96 | assert.Len(t, actual.RawGroups, 1) 97 | assert.Len(t, actual.RawHooks, 2) 98 | assert.Len(t, actual.RawNetworks, 1) 99 | assert.Len(t, actual.RawVolumes, 1) 100 | assert.NotEmpty(t, actual.RawHooks["default"].RawPreStart) 101 | assert.NotEmpty(t, actual.RawHooks["default"].RawPostStart) 102 | 103 | yaml := []byte( 104 | `services: 105 | apache: 106 | build: 107 | dockerfile: "apache" 108 | image: michaelsauter/apache 109 | volumes-from: ["crane_app"] 110 | publish: ["80:80"] 111 | networks: 112 | foo: 113 | alias: 114 | - "bar" 115 | - "baz" 116 | ip: "192.168.0.1" 117 | env: 118 | foo: 1234 119 | 4567: bar 120 | true: false 121 | label: 122 | - foo=1234 123 | - 4567=bar 124 | - true=false 125 | link: ["crane_mysql:db", "crane_memcached:cache"] 126 | detach: true 127 | groups: 128 | default: 129 | - apache 130 | hooks: 131 | apache: 132 | post-stop: echo apache container stopped!\n 133 | default: 134 | pre-start: echo start... 135 | post-start: echo start done! 136 | networks: 137 | foo: {} 138 | volumes: 139 | bar: {} 140 | `) 141 | actual = unmarshal(yaml, ".yml") 142 | assert.Len(t, actual.RawContainers, 1) 143 | assert.Len(t, actual.RawContainers["apache"].Env(), 3) 144 | assert.Len(t, actual.RawContainers["apache"].Label(), 3) 145 | assert.Len(t, actual.RawContainers["apache"].Link(), 2) 146 | networks = actual.RawContainers["apache"].Networks() 147 | assert.Len(t, networks, 1) 148 | assert.Equal(t, []string{"bar", "baz"}, networks["foo"].Alias("apache")) 149 | assert.Equal(t, "192.168.0.1", networks["foo"].Ip()) 150 | assert.Equal(t, []string{"apache"}, networks["default"].Alias("apache")) 151 | assert.Len(t, actual.RawGroups, 1) 152 | assert.Len(t, actual.RawHooks, 2) 153 | assert.Len(t, actual.RawNetworks, 1) 154 | assert.Len(t, actual.RawVolumes, 1) 155 | assert.NotEmpty(t, actual.RawHooks["default"].RawPreStart) 156 | assert.NotEmpty(t, actual.RawHooks["default"].RawPostStart) 157 | } 158 | 159 | func TestUnmarshalInvalidJSON(t *testing.T) { 160 | json := []byte( 161 | `{ 162 | "services": { 163 | "apache": { 164 | "image": "michaelsauter/apache", 165 | "publish": "shouldbeanarray" 166 | } 167 | } 168 | } 169 | `) 170 | assert.Panics(t, func() { 171 | unmarshal(json, ".json") 172 | }) 173 | } 174 | 175 | func TestUnmarshalInvalidYAML(t *testing.T) { 176 | yaml := []byte( 177 | `services: 178 | apache: 179 | image: michaelsauter/apache 180 | publish: "shouldbeanarray" 181 | `) 182 | assert.Panics(t, func() { 183 | unmarshal(yaml, ".yml") 184 | }) 185 | 186 | yaml = []byte( 187 | `services: 188 | apache: 189 | image: michaelsauter/apache 190 | env: 191 | - shouldbe: astring 192 | `) 193 | assert.Panics(t, func() { 194 | config := unmarshal(yaml, ".yml") 195 | config.RawContainers["apache"].Env() 196 | }) 197 | 198 | yaml = []byte( 199 | `services: 200 | apache: 201 | image: michaelsauter/apache 202 | env: 203 | foo: 204 | - shouldbeastring 205 | `) 206 | assert.Panics(t, func() { 207 | config := unmarshal(yaml, ".yml") 208 | config.RawContainers["apache"].Env() 209 | }) 210 | 211 | yaml = []byte( 212 | `services: 213 | apache: 214 | image: michaelsauter/apache 215 | env: 216 | foo: 217 | should: beastring 218 | `) 219 | assert.Panics(t, func() { 220 | config := unmarshal(yaml, ".yml") 221 | config.RawContainers["apache"].Env() 222 | }) 223 | } 224 | 225 | func TestUnmarshalEmptyNetworkOrVolume(t *testing.T) { 226 | yaml := []byte( 227 | `networks: 228 | foo: 229 | volumes: 230 | bar: 231 | `) 232 | config := unmarshal(yaml, ".yml") 233 | config.setNetworkMap() 234 | config.setVolumeMap() 235 | assert.Equal(t, "foo", config.networkMap["foo"].Name()) 236 | assert.Equal(t, "bar", config.volumeMap["bar"].Name()) 237 | } 238 | 239 | func TestInitialize(t *testing.T) { 240 | // use different, undefined environment variables throughout the config to detect any issue in expansion 241 | rawContainerMap := map[string]*container{ 242 | "${UNDEFINED1}a": &container{}, 243 | "${UNDEFINED2}b": &container{}, 244 | } 245 | rawGroups := map[string][]string{ 246 | "${UNDEFINED3}default": []string{ 247 | "${UNDEFINED4}a", 248 | "${UNDEFINED4}b", 249 | }, 250 | } 251 | rawHooksMap := map[string]hooks{ 252 | "${UNDEFINED5}default": hooks{ 253 | RawPreStart: "${UNDEFINED6}default-pre-start", 254 | RawPostStart: "${UNDEFINED7}default-post-start", 255 | }, 256 | "${UNDEFINED8}a": hooks{ 257 | RawPreStart: "${UNDEFINED9}custom-pre-start", 258 | }, 259 | } 260 | c := &config{ 261 | RawContainers: rawContainerMap, 262 | RawGroups: rawGroups, 263 | RawHooks: rawHooksMap, 264 | } 265 | c.initialize("") 266 | assert.Equal(t, "a", c.containerMap["a"].Name()) 267 | assert.Equal(t, "b", c.containerMap["b"].Name()) 268 | assert.Equal(t, map[string][]string{"default": []string{"a", "b"}}, c.groups) 269 | assert.Equal(t, "custom-pre-start", c.containerMap["a"].Hooks().PreStart(), "Container should have a custom pre-start hook overriding the default one") 270 | assert.Equal(t, "default-post-start", c.containerMap["a"].Hooks().PostStart(), "Container should have a default post-start hook") 271 | assert.Equal(t, "default-pre-start", c.containerMap["b"].Hooks().PreStart(), "Container should have a default post-start hook") 272 | assert.Equal(t, "default-post-start", c.containerMap["b"].Hooks().PostStart(), "Container should have a default post-start hook") 273 | } 274 | 275 | func TestInitializeAmbiguousHooks(t *testing.T) { 276 | rawContainerMap := map[string]*container{ 277 | "a": &container{}, 278 | "b": &container{}, 279 | } 280 | rawGroups := map[string][]string{ 281 | "group1": []string{"a"}, 282 | "group2": []string{"a", "b"}, 283 | } 284 | rawHooksMap := map[string]hooks{ 285 | "group1": hooks{RawPreStart: "group1-pre-start"}, 286 | "group2": hooks{RawPreStart: "group2-pre-start"}, 287 | } 288 | c := &config{ 289 | RawContainers: rawContainerMap, 290 | RawGroups: rawGroups, 291 | RawHooks: rawHooksMap, 292 | } 293 | assert.Panics(t, func() { 294 | c.initialize("") 295 | }) 296 | } 297 | 298 | func TestValidate(t *testing.T) { 299 | rawContainerMap := map[string]*container{ 300 | "a": &container{RawName: "a", RawImage: "ubuntu"}, 301 | "b": &container{RawName: "b", RawImage: "ubuntu"}, 302 | } 303 | c := &config{RawContainers: rawContainerMap} 304 | assert.NotPanics(t, func() { 305 | c.validate() 306 | }) 307 | rawContainerMap = map[string]*container{ 308 | "a": &container{RawName: "a", RawImage: "ubuntu"}, 309 | "b": &container{RawName: "b"}, 310 | } 311 | c = &config{RawContainers: rawContainerMap} 312 | assert.Panics(t, func() { 313 | c.validate() 314 | }) 315 | } 316 | 317 | func TestDependencyMap(t *testing.T) { 318 | defer func() { 319 | allowed = []string{} 320 | }() 321 | 322 | containerMap := NewStubbedContainerMap(true, 323 | &container{RawName: "a", RawLink: []string{"b:b"}}, 324 | &container{RawName: "b", RawLink: []string{"c:c"}}, 325 | &container{RawName: "c"}, 326 | ) 327 | c := &config{containerMap: containerMap} 328 | 329 | allowed = []string{"a", "b", "c"} 330 | dependencyMap := c.DependencyMap() 331 | assert.Len(t, dependencyMap, 3) 332 | // make sure a new map is returned each time 333 | delete(dependencyMap, "a") 334 | assert.Len(t, c.DependencyMap(), 3) 335 | 336 | allowed = []string{"a", "c"} 337 | dependencyMap = c.DependencyMap() 338 | assert.Len(t, dependencyMap, 2) 339 | } 340 | 341 | func TestContainersForReference(t *testing.T) { 342 | var containers []string 343 | containerMap := NewStubbedContainerMap(true, 344 | &container{RawName: "a"}, 345 | &container{RawName: "b"}, 346 | &container{RawName: "c"}, 347 | ) 348 | 349 | // No target given 350 | // If default group exist, it returns its containers 351 | groups := map[string][]string{ 352 | "default": []string{"a", "b"}, 353 | } 354 | c := &config{containerMap: containerMap, groups: groups} 355 | containers = c.ContainersForReference("") 356 | assert.Equal(t, []string{"a", "b"}, containers) 357 | // If no default group, returns all containers 358 | c = &config{containerMap: containerMap} 359 | containers = c.ContainersForReference("") 360 | sort.Strings(containers) 361 | assert.Equal(t, []string{"a", "b", "c"}, containers) 362 | // Target given 363 | // Target is a group 364 | groups = map[string][]string{ 365 | "second": []string{"b", "c"}, 366 | } 367 | c = &config{containerMap: containerMap, groups: groups} 368 | containers = c.ContainersForReference("second") 369 | assert.Equal(t, []string{"b", "c"}, containers) 370 | // Target is a container 371 | containers = c.ContainersForReference("a") 372 | assert.Equal(t, []string{"a"}, containers) 373 | } 374 | 375 | func TestContainersForReferenceInvalidReference(t *testing.T) { 376 | containerMap := NewStubbedContainerMap(true, 377 | &container{RawName: "a"}, 378 | &container{RawName: "b"}, 379 | ) 380 | groups := map[string][]string{ 381 | "foo": []string{"a", "doesntexist", "b"}, 382 | } 383 | c := &config{containerMap: containerMap, groups: groups} 384 | assert.Panics(t, func() { 385 | c.ContainersForReference("foo") 386 | }) 387 | assert.Panics(t, func() { 388 | c.ContainersForReference("doesntexist") 389 | }) 390 | } 391 | 392 | func TestContainersForReferenceDeduplication(t *testing.T) { 393 | containerMap := NewStubbedContainerMap(true, 394 | &container{RawName: "a"}, 395 | &container{RawName: "b"}, 396 | ) 397 | groups := map[string][]string{ 398 | "foo": []string{"a", "b", "a"}, 399 | } 400 | c := &config{containerMap: containerMap, groups: groups} 401 | containers := c.ContainersForReference("foo") 402 | assert.Equal(t, []string{"a", "b"}, containers) 403 | } 404 | 405 | func TestNetworkNames(t *testing.T) { 406 | var networks []string 407 | var networkMap map[string]Network 408 | var c Config 409 | 410 | networkMap = map[string]Network{} 411 | c = &config{networkMap: networkMap} 412 | networks = c.NetworkNames() 413 | assert.Equal(t, []string{}, networks) 414 | 415 | networkMap = map[string]Network{ 416 | "foo": &network{}, 417 | "bar": &network{}, 418 | } 419 | c = &config{networkMap: networkMap} 420 | networks = c.NetworkNames() 421 | assert.Equal(t, []string{"bar", "foo"}, networks) 422 | } 423 | 424 | func TestVolumeNames(t *testing.T) { 425 | var volumes []string 426 | var volumeMap map[string]Volume 427 | var c Config 428 | 429 | volumeMap = map[string]Volume{} 430 | c = &config{volumeMap: volumeMap} 431 | volumes = c.VolumeNames() 432 | assert.Equal(t, []string{}, volumes) 433 | 434 | volumeMap = map[string]Volume{ 435 | "foo": &network{}, 436 | "bar": &network{}, 437 | } 438 | c = &config{volumeMap: volumeMap} 439 | volumes = c.VolumeNames() 440 | assert.Equal(t, []string{"bar", "foo"}, volumes) 441 | } 442 | 443 | func TestConfigNetwork(t *testing.T) { 444 | var networkMap map[string]*network 445 | var c *config 446 | 447 | networkMap = map[string]*network{} 448 | c = &config{RawNetworks: networkMap} 449 | c.setNetworkMap() 450 | assert.Equal(t, nil, c.Network("foo")) 451 | 452 | networkMap = map[string]*network{ 453 | "foo": &network{}, 454 | "bar": &network{}, 455 | } 456 | c = &config{RawNetworks: networkMap} 457 | c.setNetworkMap() 458 | assert.Equal(t, "bar", c.Network("bar").Name()) 459 | } 460 | 461 | func TestConfigVolume(t *testing.T) { 462 | var rawVolumes map[string]*volume 463 | var c *config 464 | 465 | rawVolumes = map[string]*volume{} 466 | c = &config{RawVolumes: rawVolumes} 467 | c.setVolumeMap() 468 | assert.Equal(t, nil, c.Volume("foo")) 469 | 470 | rawVolumes = map[string]*volume{ 471 | "foo": &volume{}, 472 | "bar": &volume{}, 473 | } 474 | c = &config{RawVolumes: rawVolumes} 475 | c.setVolumeMap() 476 | assert.Equal(t, "bar", c.Volume("bar").Name()) 477 | } 478 | -------------------------------------------------------------------------------- /crane/container_test.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | yaml "gopkg.in/yaml.v2" 10 | ) 11 | 12 | func TestDependencies(t *testing.T) { 13 | defer func() { 14 | allowed = []string{} 15 | }() 16 | 17 | c := &container{} 18 | expected := &Dependencies{} 19 | 20 | // no dependencies 21 | assert.Equal(t, expected, c.Dependencies()) 22 | 23 | // network v2 links 24 | allowed = []string{"foo", "bar", "c"} 25 | c = &container{ 26 | RawRequires: []string{"foo", "bar"}, 27 | RawNet: "network", 28 | RawLink: []string{"a:b", "b:d"}, 29 | RawVolumesFrom: []string{"c"}, 30 | } 31 | expected = &Dependencies{ 32 | All: []string{"foo", "bar", "c"}, 33 | Requires: []string{"foo", "bar"}, 34 | VolumesFrom: []string{"c"}, 35 | } 36 | assert.Equal(t, expected, c.Dependencies()) 37 | 38 | // legacy links 39 | allowed = []string{"a", "b", "c"} 40 | c = &container{ 41 | RawNet: "bridge", 42 | RawLink: []string{"a:b", "b:d"}, 43 | RawVolumesFrom: []string{"c"}, 44 | } 45 | expected = &Dependencies{ 46 | All: []string{"a", "b", "c"}, 47 | Link: []string{"a", "b"}, 48 | VolumesFrom: []string{"c"}, 49 | } 50 | assert.Equal(t, expected, c.Dependencies()) 51 | 52 | // container network 53 | allowed = []string{"c", "n"} 54 | c = &container{ 55 | RawNet: "container:n", 56 | RawVolumesFrom: []string{"c"}, 57 | } 58 | expected = &Dependencies{ 59 | All: []string{"c", "n"}, 60 | VolumesFrom: []string{"c"}, 61 | Net: "n", 62 | } 63 | assert.Equal(t, expected, c.Dependencies()) 64 | 65 | // with restricted allowed containers 66 | allowed = []string{"foo", "c"} 67 | c = &container{ 68 | RawRequires: []string{"foo", "bar"}, 69 | RawLink: []string{"a:b", "b:d"}, 70 | RawVolumesFrom: []string{"c", "d"}, 71 | } 72 | expected = &Dependencies{ 73 | All: []string{"foo", "c"}, 74 | Requires: []string{"foo"}, 75 | VolumesFrom: []string{"c"}, 76 | } 77 | assert.Equal(t, expected, c.Dependencies()) 78 | } 79 | 80 | func TestVolumesFromSuffixes(t *testing.T) { 81 | defer func() { 82 | allowed = []string{} 83 | }() 84 | allowed = []string{"a", "b"} 85 | c := &container{RawVolumesFrom: []string{"a:rw", "b:ro"}} 86 | expected := &Dependencies{ 87 | All: []string{"a", "b"}, 88 | VolumesFrom: []string{"a", "b"}, 89 | } 90 | assert.Equal(t, expected, c.Dependencies()) 91 | } 92 | 93 | func TestMultipleLinkAliases(t *testing.T) { 94 | defer func() { 95 | allowed = []string{} 96 | }() 97 | allowed = []string{"a"} 98 | c := &container{RawNet: "bridge", RawLink: []string{"a:b", "a:c"}} 99 | expected := &Dependencies{ 100 | All: []string{"a"}, 101 | Link: []string{"a"}, 102 | } 103 | assert.Equal(t, expected, c.Dependencies()) 104 | } 105 | 106 | func TestImplicitLinkAliases(t *testing.T) { 107 | defer func() { 108 | allowed = []string{} 109 | }() 110 | allowed = []string{"a"} 111 | c := &container{RawNet: "bridge", RawLink: []string{"a"}} 112 | expected := &Dependencies{ 113 | All: []string{"a"}, 114 | Link: []string{"a"}, 115 | } 116 | assert.Equal(t, expected, c.Dependencies()) 117 | } 118 | 119 | func TestImage(t *testing.T) { 120 | containers := []*container{ 121 | &container{RawName: "full-spec", RawImage: "test/image-a:1.0"}, 122 | &container{RawName: "without-repo", RawImage: "image-b:latest"}, 123 | &container{RawName: "without-tag", RawImage: "test/image-c"}, 124 | &container{RawName: "image-only", RawImage: "image-d"}, 125 | &container{RawName: "private-registry", RawImage: "localhost:5000/foo/image-e:2.0"}, 126 | &container{RawName: "private-registry-without-tag", RawImage: "localhost:5000/foo/image-e"}, 127 | &container{RawName: "digest", RawImage: "localhost:5000/foo/image-f@sha256:xxx"}, 128 | } 129 | containerMap := make(map[string]*container) 130 | for _, container := range containers { 131 | containerMap[container.Name()] = container 132 | } 133 | cfg = &config{ 134 | tag: "rc-1", 135 | } 136 | 137 | assert.Equal(t, "test/image-a:rc-1", containerMap["full-spec"].Image()) 138 | 139 | assert.Equal(t, "image-b:rc-1", containerMap["without-repo"].Image()) 140 | 141 | assert.Equal(t, "test/image-c:rc-1", containerMap["without-tag"].Image()) 142 | 143 | assert.Equal(t, "image-d:rc-1", containerMap["image-only"].Image()) 144 | 145 | assert.Equal(t, "localhost:5000/foo/image-e:rc-1", containerMap["private-registry"].Image()) 146 | 147 | assert.Equal(t, "localhost:5000/foo/image-e:rc-1", containerMap["private-registry-without-tag"].Image()) 148 | 149 | assert.NotEqual(t, "localhost:5000/foo/image-f@sha256:rc-1", containerMap["digest"].Image()) 150 | } 151 | 152 | func TestVolume(t *testing.T) { 153 | var c *container 154 | // Absolute path 155 | c = &container{RawVolume: []string{"/a:/b"}} 156 | cfg = &config{path: "foo"} 157 | assert.Equal(t, "/a:/b", c.Volume()[0]) 158 | // Environment variable 159 | c = &container{RawVolume: []string{"$HOME/a:/b"}} 160 | os.Clearenv() 161 | os.Setenv("HOME", "/home") 162 | cfg = &config{path: "foo"} 163 | assert.Equal(t, os.Getenv("HOME")+"/a:/b", c.Volume()[0]) 164 | } 165 | 166 | func TestActualVolumeArg(t *testing.T) { 167 | // Simple case 168 | cfg = &config{path: "foo"} 169 | assert.Equal(t, "/a:/b", actualVolumeArg("/a:/b")) 170 | // Relative path 171 | dir, _ := os.Getwd() 172 | cfg = &config{path: dir} 173 | assert.Equal(t, dir+"/a:/b", actualVolumeArg("a:/b")) 174 | // Container-only path 175 | assert.Equal(t, "/b", actualVolumeArg("/b")) 176 | // Using Docker volume 177 | cfg = &config{volumeMap: map[string]Volume{"a": &volume{RawName: "a"}}} 178 | assert.Equal(t, "a:/b", actualVolumeArg("a:/b")) 179 | // With prefix Docker volume 180 | cfg = &config{prefix: "foo_", volumeMap: map[string]Volume{"a": &volume{RawName: "a"}}} 181 | assert.Equal(t, "foo_a:/b", actualVolumeArg("a:/b")) 182 | } 183 | 184 | func TestNet(t *testing.T) { 185 | var c *container 186 | // Environment variable 187 | os.Clearenv() 188 | os.Setenv("NET", "container") 189 | c = &container{RawNet: "$NET"} 190 | assert.Equal(t, "container", c.Net()) 191 | } 192 | 193 | func TestActualNet(t *testing.T) { 194 | var c *container 195 | // Empty defaults to "" 196 | c = &container{} 197 | assert.Equal(t, "", c.ActualNet()) 198 | // Container 199 | c = &container{RawName: "foo", RawNet: "container:foo"} 200 | cfg = &config{containerMap: map[string]Container{"foo": c}} 201 | assert.Equal(t, "container:foo", c.ActualNet()) 202 | // Network 203 | c = &container{RawName: "foo", RawNet: "bar"} 204 | cfg = &config{ 205 | containerMap: map[string]Container{"foo": c}, 206 | networkMap: map[string]Network{"bar": &network{RawName: "bar"}}, 207 | } 208 | assert.Equal(t, "bar", c.ActualNet()) 209 | // Network with prefix 210 | cfg = &config{ 211 | prefix: "qux_", 212 | containerMap: map[string]Container{"foo": c}, 213 | networkMap: map[string]Network{"bar": &network{RawName: "bar"}}, 214 | } 215 | assert.Equal(t, "qux_bar", c.ActualNet()) 216 | } 217 | 218 | func TestNetworks(t *testing.T) { 219 | var c *container 220 | // automatically has default network configured for every container 221 | c = &container{RawName: "foo"} 222 | cfg = &config{ 223 | containerMap: map[string]Container{"foo": c}, 224 | networkMap: map[string]Network{"default": &network{RawName: "default"}}, 225 | } 226 | assert.Contains(t, c.Networks(), "default") 227 | } 228 | 229 | func TestCmd(t *testing.T) { 230 | var c *container 231 | // String 232 | os.Clearenv() 233 | os.Setenv("CMD", "true") 234 | c = &container{RawCmd: "$$CMD is $CMD"} 235 | assert.Equal(t, []string{"$CMD", "is", "true"}, c.Cmd()) 236 | // String with multiple parts 237 | c = &container{RawCmd: "bundle exec rails s -p 3000"} 238 | assert.Equal(t, []string{"bundle", "exec", "rails", "s", "-p", "3000"}, c.Cmd()) 239 | // Array 240 | os.Clearenv() 241 | os.Setenv("CMD", "1") 242 | c = &container{RawCmd: []interface{}{"echo", "$CMD", "$$CMD"}} 243 | assert.Equal(t, []string{"echo", "1", "$CMD"}, c.Cmd()) 244 | } 245 | 246 | type OptIntWrapper struct { 247 | OptInt OptInt `json:"OptInt" yaml:"OptInt"` 248 | } 249 | 250 | func TestOptIntJSON(t *testing.T) { 251 | wrapper := OptIntWrapper{} 252 | json.Unmarshal([]byte("{\"OptInt\": 1}"), &wrapper) 253 | assert.Equal(t, OptInt{Defined: true, Value: 1}, wrapper.OptInt) 254 | 255 | wrapper = OptIntWrapper{} 256 | json.Unmarshal([]byte("{}"), &wrapper) 257 | assert.False(t, wrapper.OptInt.Defined) 258 | 259 | wrapper = OptIntWrapper{} 260 | err := json.Unmarshal([]byte("{\"OptInt\": \"notanumber\"}"), &wrapper) 261 | assert.Error(t, err) 262 | } 263 | 264 | func TestOptIntYAML(t *testing.T) { 265 | wrapper := OptIntWrapper{} 266 | yaml.Unmarshal([]byte("OptInt: 1"), &wrapper) 267 | assert.Equal(t, OptInt{Defined: true, Value: 1}, wrapper.OptInt) 268 | 269 | wrapper = OptIntWrapper{} 270 | yaml.Unmarshal([]byte(""), &wrapper) 271 | assert.False(t, wrapper.OptInt.Defined) 272 | 273 | wrapper = OptIntWrapper{} 274 | err := yaml.Unmarshal([]byte("OptInt: notanumber"), &wrapper) 275 | assert.Error(t, err) 276 | } 277 | 278 | type OptBoolWrapper struct { 279 | OptBool OptBool `json:"OptBool" yaml:"OptBool"` 280 | } 281 | 282 | func TestOptBoolJSON(t *testing.T) { 283 | wrapper := OptBoolWrapper{} 284 | json.Unmarshal([]byte("{\"OptBool\": true}"), &wrapper) 285 | assert.Equal(t, OptBool{Defined: true, Value: true}, wrapper.OptBool) 286 | 287 | wrapper = OptBoolWrapper{} 288 | json.Unmarshal([]byte("{\"OptBool\": false}"), &wrapper) 289 | assert.Equal(t, OptBool{Defined: true, Value: false}, wrapper.OptBool) 290 | 291 | wrapper = OptBoolWrapper{} 292 | json.Unmarshal([]byte("{}"), &wrapper) 293 | assert.False(t, wrapper.OptBool.Defined) 294 | 295 | wrapper = OptBoolWrapper{} 296 | err := json.Unmarshal([]byte("{\"OptBool\": \"notaboolean\"}"), &wrapper) 297 | assert.Error(t, err) 298 | } 299 | 300 | func TestOptBoolYAML(t *testing.T) { 301 | wrapper := OptBoolWrapper{} 302 | yaml.Unmarshal([]byte("OptBool: true"), &wrapper) 303 | assert.Equal(t, OptBool{Defined: true, Value: true}, wrapper.OptBool) 304 | 305 | wrapper = OptBoolWrapper{} 306 | yaml.Unmarshal([]byte("OptBool: false"), &wrapper) 307 | assert.Equal(t, OptBool{Defined: true, Value: false}, wrapper.OptBool) 308 | 309 | wrapper = OptBoolWrapper{} 310 | yaml.Unmarshal([]byte(""), &wrapper) 311 | assert.False(t, wrapper.OptBool.Defined) 312 | 313 | wrapper = OptBoolWrapper{} 314 | err := yaml.Unmarshal([]byte("OptBool: notaboolean"), &wrapper) 315 | assert.Error(t, err) 316 | } 317 | 318 | func TestBuildArgs(t *testing.T) { 319 | var c *container 320 | c = &container{RawBuild: BuildParameters{RawBuildArgs: []interface{}{"key1=value1"}}} 321 | cfg = &config{path: "foo"} 322 | assert.Equal(t, "key1=value1", c.BuildParams().BuildArgs()[0]) 323 | } 324 | -------------------------------------------------------------------------------- /crane/containers.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "text/tabwriter" 12 | 13 | "github.com/bjaglin/multiplexio" 14 | ansi "github.com/fatih/color" 15 | ) 16 | 17 | type Containers []Container 18 | 19 | func (containers Containers) Reversed() Containers { 20 | var reversed []Container 21 | for i := len(containers) - 1; i >= 0; i-- { 22 | reversed = append(reversed, containers[i]) 23 | } 24 | return reversed 25 | } 26 | 27 | // Provision containers. 28 | func (containers Containers) Provision(nocache bool, parallel int) { 29 | var ( 30 | throttle = make(chan struct{}, parallel) 31 | wg sync.WaitGroup 32 | ) 33 | if parallel != 1 { 34 | // As we won't dump the result of provisioning until the first 35 | // image succeeds, give some early feedback so that the operator 36 | // does not feel it's stuck 37 | fmt.Println("Provisioning containers ...") 38 | } 39 | for _, container := range containers.stripProvisioningDuplicates() { 40 | if parallel > 0 { 41 | throttle <- struct{}{} 42 | } 43 | wg.Add(1) 44 | go func(container Container) { 45 | var out, err bytes.Buffer 46 | defer func() { 47 | out.WriteTo(os.Stdout) 48 | err.WriteTo(os.Stderr) 49 | handleRecoveredError(recover()) 50 | if parallel > 0 { 51 | <-throttle 52 | } 53 | wg.Done() 54 | container.SetCommandsOutput(nil, nil) 55 | }() 56 | if parallel != 1 { 57 | // Prevent parallel provisioning output interlacing 58 | // by redirecting the outputs to buffers 59 | container.SetCommandsOutput(&out, &err) 60 | } 61 | container.Provision(nocache) 62 | }(container) 63 | } 64 | wg.Wait() 65 | } 66 | 67 | // Dump container logs. 68 | func (containers Containers) Logs(follow bool, timestamps bool, tail string, colorize bool, since string) { 69 | var ( 70 | sources = make([]multiplexio.Source, 0, 2*len(containers)) 71 | maxPrefixLength = strconv.Itoa(containers.maxNameLength()) 72 | ) 73 | appendSources := func(reader io.Reader, color *ansi.Color, name string, separator string) { 74 | if reader != nil { 75 | prefix := fmt.Sprintf("%"+maxPrefixLength+"s "+separator+" ", name) 76 | sources = append(sources, multiplexio.Source{ 77 | Reader: reader, 78 | Write: write(prefix, color, timestamps), 79 | }) 80 | } 81 | } 82 | counter := 0 83 | for _, container := range containers { 84 | var ( 85 | logs = container.Logs(follow, since, tail) 86 | stdoutColor *ansi.Color 87 | stderrColor *ansi.Color 88 | ) 89 | for _, log := range logs { 90 | if colorize { 91 | // red has a negative/error connotation, so skip it 92 | ansiAttribute := ansi.Attribute(int(ansi.FgGreen) + counter%int(ansi.FgWhite-ansi.FgGreen)) 93 | stdoutColor = ansi.New(ansiAttribute) 94 | // To synchronize their output, we need to multiplex stdout & stderr 95 | // onto the same stream. Unfortunately, that means that the user won't 96 | // be able to pipe them separately, so we use bold as a distinguishing 97 | // characteristic. 98 | stderrColor = ansi.New(ansiAttribute).Add(ansi.Bold) 99 | } 100 | appendSources(log.Stdout, stdoutColor, log.Name, "|") 101 | appendSources(log.Stderr, stderrColor, log.Name, "*") 102 | counter += 1 103 | } 104 | } 105 | if len(sources) > 0 { 106 | aggregatedReader := multiplexio.NewReader(multiplexio.Options{}, sources...) 107 | io.Copy(os.Stdout, aggregatedReader) 108 | } 109 | } 110 | 111 | // Status of containers. 112 | func (containers Containers) Status(notrunc bool) { 113 | w := new(tabwriter.Writer) 114 | w.Init(os.Stdout, 0, 8, 1, '\t', 0) 115 | fmt.Fprintln(w, "NAME\tIMAGE\tID\tUP TO DATE\tIP\tPORTS\tRUNNING") 116 | for _, container := range containers { 117 | rows := container.Status() 118 | for _, fields := range rows { 119 | if !notrunc { 120 | fields[2] = truncateID(fields[2]) 121 | } 122 | fmt.Fprintf(w, "%s\n", strings.Join(fields, "\t")) 123 | } 124 | } 125 | w.Flush() 126 | } 127 | 128 | // Return the length of the longest container name. 129 | func (containers Containers) maxNameLength() (maxPrefixLength int) { 130 | for _, container := range containers { 131 | prefixLength := len(container.ActualName(false)) 132 | if prefixLength > maxPrefixLength { 133 | maxPrefixLength = prefixLength 134 | } 135 | } 136 | return 137 | } 138 | 139 | // returns another list of containers, stripping out containers which 140 | // would trigger some commands more than once for provisioning. 141 | func (containers Containers) stripProvisioningDuplicates() (deduplicated Containers) { 142 | seenProvisioningKeys := make(map[string]bool) 143 | for _, container := range containers { 144 | // for 2 containers that would the same provisioning 145 | // commands, the key should be equal 146 | key := container.BuildParams().Context() + "#" + container.Image() 147 | if _, ok := seenProvisioningKeys[key]; !ok { 148 | deduplicated = append(deduplicated, container) 149 | seenProvisioningKeys[key] = true 150 | } 151 | } 152 | return 153 | } 154 | 155 | func truncateID(id string) string { 156 | shortLen := 12 157 | if len(id) < shortLen { 158 | shortLen = len(id) 159 | } 160 | return id[:shortLen] 161 | } 162 | 163 | // wraps an io.Writer, counting the number of bytes written 164 | type countingWriter struct { 165 | io.Writer 166 | written int 167 | } 168 | 169 | func (c *countingWriter) Write(p []byte) (n int, err error) { 170 | n, err = c.Writer.Write(p) 171 | c.written += n 172 | return 173 | } 174 | 175 | // returns a function that will format and writes the line extracted from the logs of a given container 176 | func write(prefix string, color *ansi.Color, timestamps bool) func(dest io.Writer, token []byte) (n int, err error) { 177 | return func(dest io.Writer, token []byte) (n int, err error) { 178 | countingWriter := countingWriter{Writer: dest} 179 | if color != nil { 180 | ansi.Output = &countingWriter 181 | color.Set() 182 | } 183 | _, err = countingWriter.Write([]byte(prefix)) 184 | if err == nil { 185 | if !timestamps { 186 | // timestamps are always present in the incoming stream for 187 | // sorting purposes, so we strip them if the user didn't ask 188 | // for them 189 | const timestampPrefixLength = 31 190 | strip := timestampPrefixLength 191 | if string(token[0]) == "[" { 192 | // it seems that timestamps are wrapped in [] for events 193 | // streamed in real time during a `docker logs -f` 194 | strip += 2 195 | } 196 | token = token[strip:] 197 | } 198 | _, err = countingWriter.Write(token) 199 | } 200 | if err == nil { 201 | if color != nil { 202 | ansi.Unset() 203 | } 204 | _, err = dest.Write([]byte("\n")) 205 | } 206 | return countingWriter.written, err 207 | 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /crane/containers_test.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestReversed(t *testing.T) { 10 | var containers Containers 11 | containers = []Container{&container{RawName: "a"}, &container{RawName: "b"}} 12 | reversed := containers.Reversed() 13 | assert.Len(t, reversed, 2) 14 | assert.Equal(t, "b", reversed[0].Name()) 15 | assert.Equal(t, "a", reversed[1].Name()) 16 | } 17 | 18 | func TestProvisioningDuplicates(t *testing.T) { 19 | var containers Containers 20 | containers = []Container{ 21 | &container{RawName: "A", RawBuild: BuildParameters{RawContext: "dockerfile1"}, RawImage: "image1"}, 22 | &container{RawName: "B", RawBuild: BuildParameters{RawContext: "dockerfile1"}, RawImage: "image1"}, //dup of A 23 | &container{RawName: "C", RawBuild: BuildParameters{RawContext: "dockerfile1"}, RawImage: "image2"}, 24 | &container{RawName: "D", RawBuild: BuildParameters{RawContext: "dockerfile1"}, RawImage: "image2"}, //dup of C 25 | &container{RawName: "E", RawBuild: BuildParameters{RawContext: "dockerfile2"}, RawImage: "image1"}, 26 | &container{RawName: "F", RawBuild: BuildParameters{RawContext: "dockerfile2"}, RawImage: "image1"}, //dup of E 27 | &container{RawName: "G", RawBuild: BuildParameters{RawContext: "dockerfile1"}}, // image is "G" 28 | &container{RawName: "H", RawBuild: BuildParameters{RawContext: "dockerfile1"}}, // image is "H" 29 | &container{RawName: "I", RawImage: "image1"}, 30 | &container{RawName: "J", RawImage: "image1"}, //dup of I 31 | } 32 | deduplicated := containers.stripProvisioningDuplicates() 33 | assert.Len(t, deduplicated, 6) 34 | assert.Len(t, containers, 10) // input was not mutated - further operations won't be affected 35 | } 36 | -------------------------------------------------------------------------------- /crane/crane.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "syscall" 13 | 14 | "github.com/fatih/color" 15 | shlex "github.com/flynn/go-shlex" 16 | ) 17 | 18 | var printSuccessf func(format string, a ...interface{}) 19 | var printInfof func(format string, a ...interface{}) 20 | var printNoticef func(format string, a ...interface{}) 21 | var printErrorf func(format string, a ...interface{}) 22 | 23 | func init() { 24 | color.Output = os.Stderr 25 | printSuccessf = color.New(color.FgGreen).PrintfFunc() 26 | printInfof = color.New(color.FgBlue).PrintfFunc() 27 | printNoticef = color.New(color.FgYellow).PrintfFunc() 28 | printErrorf = color.New(color.FgRed).PrintfFunc() 29 | } 30 | 31 | type StatusError struct { 32 | error error 33 | status int 34 | } 35 | 36 | func handleRecoveredError(recovered interface{}) { 37 | if recovered == nil { 38 | return 39 | } 40 | 41 | var statusError StatusError 42 | 43 | switch err := recovered.(type) { 44 | case StatusError: 45 | statusError = err 46 | case error: 47 | statusError = StatusError{err, 1} 48 | case string: 49 | statusError = StatusError{errors.New(err), 1} 50 | default: 51 | statusError = StatusError{} 52 | } 53 | 54 | if statusError.error != nil { 55 | printErrorf("ERROR: %s\n", statusError.error) 56 | } 57 | os.Exit(statusError.status) 58 | } 59 | 60 | var requiredDockerVersion = []int{1, 13} 61 | 62 | func RealMain() { 63 | // On panic, recover the error, display it and return the given status code if any 64 | defer func() { 65 | handleRecoveredError(recover()) 66 | }() 67 | checkDockerClient() 68 | runCli() 69 | } 70 | 71 | // Ensure there is a docker binary in the path, 72 | // and printing an error if its version is below the minimal requirement. 73 | func checkDockerClient() { 74 | output, err := commandOutput("docker", []string{"--version"}) 75 | if err != nil { 76 | panic(StatusError{errors.New("Error when probing Docker's client version. Is docker installed and within the $PATH?"), 69}) 77 | } 78 | re := regexp.MustCompile("([0-9]+)\\.([0-9]+)\\.?([0-9]+)?") 79 | rawVersions := re.FindStringSubmatch(output) 80 | var versions []int 81 | for _, rawVersion := range rawVersions[1:] { 82 | version, err := strconv.Atoi(rawVersion) 83 | if err != nil { 84 | printErrorf("Error when parsing Docker's version %v: %v", rawVersion, err) 85 | break 86 | } 87 | versions = append(versions, version) 88 | } 89 | 90 | for i, expectedVersion := range requiredDockerVersion { 91 | if versions[i] > expectedVersion { 92 | break 93 | } 94 | if versions[i] < expectedVersion { 95 | printErrorf("Unsupported client version! Please upgrade to Docker %v or later.\n", intJoin(requiredDockerVersion, ".")) 96 | } 97 | } 98 | } 99 | 100 | // Assemble slice of strings from slice or string with spaces 101 | func stringSlice(sliceLike interface{}) []string { 102 | var strSlice []string 103 | switch sl := sliceLike.(type) { 104 | case string: 105 | if len(sl) > 0 { 106 | parts, err := shlex.Split(expandEnv(sl)) 107 | if err != nil { 108 | printErrorf("Error when parsing cmd `%v`: %v. Proceeding with %q.", sl, err, parts) 109 | } 110 | strSlice = append(strSlice, parts...) 111 | } 112 | case []interface{}: 113 | parts := make([]string, len(sl)) 114 | for i, v := range sl { 115 | parts[i] = expandEnv(fmt.Sprintf("%v", v)) 116 | } 117 | strSlice = append(strSlice, parts...) 118 | default: 119 | panic(StatusError{fmt.Errorf("unknown type: %v", sl), 65}) 120 | } 121 | return strSlice 122 | } 123 | 124 | func equalSlices(a, b []string) bool { 125 | 126 | if a == nil && b == nil { 127 | return true 128 | } 129 | 130 | if a == nil || b == nil { 131 | return false 132 | } 133 | 134 | if len(a) != len(b) { 135 | return false 136 | } 137 | 138 | for i := range a { 139 | if a[i] != b[i] { 140 | return false 141 | } 142 | } 143 | 144 | return true 145 | } 146 | 147 | // Similar to strings.Join() for int slices. 148 | func intJoin(intSlice []int, sep string) string { 149 | var stringSlice []string 150 | for _, v := range intSlice { 151 | stringSlice = append(stringSlice, fmt.Sprint(v)) 152 | } 153 | return strings.Join(stringSlice, sep) 154 | } 155 | 156 | func executeHook(hook string, containerName string) { 157 | os.Setenv("CRANE_HOOKED_CONTAINER", containerName) 158 | cmds, err := shlex.Split(hook) 159 | if err != nil { 160 | panic(StatusError{fmt.Errorf("Error when parsing hook `%v`: %v", hook, err), 64}) 161 | } 162 | switch len(cmds) { 163 | case 0: 164 | return 165 | case 1: 166 | executeCommand(cmds[0], []string{}, os.Stdout, os.Stderr) 167 | default: 168 | executeCommand(cmds[0], cmds[1:], os.Stdout, os.Stderr) 169 | } 170 | } 171 | 172 | // Print message when verbose mode is enabled 173 | func verboseMsg(message string) { 174 | if isVerbose() { 175 | printInfof("%s\n", message) 176 | } 177 | } 178 | 179 | // Log command when verbose mode is enabled 180 | func verboseLog(message string) { 181 | if isVerbose() { 182 | printInfof("--> %s\n", message) 183 | } 184 | } 185 | 186 | func executeCommand(name string, args []string, stdout, stderr io.Writer) { 187 | verboseLog(name + " " + strings.Join(args, " ")) 188 | if !isDryRun() { 189 | cmd := exec.Command(name, args...) 190 | if cfg != nil { 191 | cmd.Dir = cfg.Path() 192 | } 193 | cmd.Stdout = stdout 194 | cmd.Stderr = stderr 195 | cmd.Stdin = os.Stdin 196 | cmd.Run() 197 | if !cmd.ProcessState.Success() { 198 | status := cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus() 199 | panic(StatusError{errors.New(cmd.ProcessState.String()), status}) 200 | } 201 | } 202 | } 203 | 204 | func executeHiddenCommand(name string, args []string) { 205 | if isVerbose() { 206 | executeCommand(name, args, os.Stdout, os.Stderr) 207 | } else { 208 | executeCommand(name, args, nil, nil) 209 | } 210 | } 211 | 212 | func executeCommandBackground(name string, args []string) (cmd *exec.Cmd, stdout io.ReadCloser, stderr io.ReadCloser) { 213 | verboseLog(name + " " + strings.Join(args, " ")) 214 | if !isDryRun() { 215 | cmd = exec.Command(name, args...) 216 | if cfg != nil { 217 | cmd.Dir = cfg.Path() 218 | } 219 | stdout, _ = cmd.StdoutPipe() 220 | stderr, _ = cmd.StderrPipe() 221 | cmd.Start() 222 | return cmd, stdout, stderr 223 | } 224 | return nil, nil, nil 225 | } 226 | 227 | func commandOutput(name string, args []string) (string, error) { 228 | cmd := exec.Command(name, args...) 229 | if cfg != nil { 230 | cmd.Dir = cfg.Path() 231 | } 232 | out, err := cmd.CombinedOutput() 233 | return strings.TrimSpace(string(out)), err 234 | } 235 | 236 | // Follow os.ExpandEnv's contract except for `$$` which is transformed to `$` 237 | func expandEnv(s string) string { 238 | os.Setenv("CRANE_DOLLAR", "$") 239 | return os.ExpandEnv(strings.Replace(s, "$$", "${CRANE_DOLLAR}", -1)) 240 | } 241 | 242 | func includes(haystack []string, needle string) bool { 243 | for _, name := range haystack { 244 | if name == needle { 245 | return true 246 | } 247 | } 248 | return false 249 | } 250 | -------------------------------------------------------------------------------- /crane/dependencies.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | // Dependencies contains 4 fields: 4 | // all: contains all dependencies 5 | // requires: containers that need to be running 6 | // link: containers linked to 7 | // volumesFrom: containers that provide volumes 8 | // net: container the net stack is shared with 9 | type Dependencies struct { 10 | All []string 11 | Requires []string 12 | Link []string 13 | VolumesFrom []string 14 | Net string 15 | IPC string 16 | } 17 | 18 | // includes checks whether the given needle is 19 | // included in the dependency list 20 | func (d *Dependencies) includes(needle string) bool { 21 | for _, name := range d.All { 22 | if name == needle { 23 | return true 24 | } 25 | } 26 | return false 27 | } 28 | 29 | // requireStarted checks whether the given needle needs 30 | // to be running in order to be satisfied. 31 | func (d *Dependencies) requireStarted(needle string) bool { 32 | if needle == d.Net || needle == d.IPC { 33 | return true 34 | } 35 | for _, name := range d.Requires { 36 | if name == needle { 37 | return true 38 | } 39 | } 40 | for _, name := range d.Link { 41 | if name == needle { 42 | return true 43 | } 44 | } 45 | return false 46 | } 47 | 48 | // satisfied is true when there are no 49 | // dependencies left. 50 | func (d *Dependencies) satisfied() bool { 51 | return len(d.All) == 0 52 | } 53 | 54 | // remove removes the given name from All 55 | func (d *Dependencies) remove(resolved string) { 56 | for i, name := range d.All { 57 | if name == resolved { 58 | d.All = append(d.All[:i], d.All[i+1:]...) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /crane/dependencies_test.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIncludes(t *testing.T) { 10 | dependencies := Dependencies{ 11 | All: []string{"required", "link", "volumesFrom", "net", "ipc"}, 12 | Requires: []string{"required"}, 13 | Link: []string{"link"}, 14 | VolumesFrom: []string{"volumesFrom"}, 15 | Net: "net", 16 | IPC: "ipc", 17 | } 18 | assert.True(t, dependencies.includes("required")) 19 | assert.True(t, dependencies.includes("link")) 20 | assert.True(t, dependencies.includes("volumesFrom")) 21 | assert.True(t, dependencies.includes("net")) 22 | assert.True(t, dependencies.includes("ipc")) 23 | assert.False(t, dependencies.includes("non-existent")) 24 | } 25 | 26 | func TestSatisfied(t *testing.T) { 27 | var dependencies Dependencies 28 | 29 | dependencies = Dependencies{ 30 | All: []string{"a"}, 31 | } 32 | assert.False(t, dependencies.satisfied(), "Dependencies was not empty, but appeared to be satisfied") 33 | 34 | dependencies = Dependencies{ 35 | All: []string{}, 36 | } 37 | assert.True(t, dependencies.satisfied(), "Dependencies was empty, but appeared not to be satisfied") 38 | } 39 | -------------------------------------------------------------------------------- /crane/healthcheck_parameters.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | type HealthcheckParameters struct { 4 | RawTest string `json:"test" yaml:"test"` 5 | RawInterval string `json:"interval" yaml:"interval"` 6 | RawTimeout string `json:"timeout" yaml:"timeout"` 7 | Retries int `json:"retries" yaml:"retries"` 8 | Disable bool `json:"disable" yaml:"disable"` 9 | } 10 | 11 | func (h HealthcheckParameters) Test() string { 12 | return expandEnv(h.RawTest) 13 | } 14 | 15 | func (h HealthcheckParameters) Interval() string { 16 | return expandEnv(h.RawInterval) 17 | } 18 | 19 | func (h HealthcheckParameters) Timeout() string { 20 | return expandEnv(h.RawTimeout) 21 | } 22 | -------------------------------------------------------------------------------- /crane/hooks.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | type Hooks interface { 4 | PreBuild() string 5 | PostBuild() string 6 | PreStart() string 7 | PostStart() string 8 | PreStop() string 9 | PostStop() string 10 | } 11 | 12 | type hooks struct { 13 | RawPreBuild string `json:"pre-build" yaml:"pre-build"` 14 | RawPostBuild string `json:"post-build" yaml:"post-build"` 15 | RawPreStart string `json:"pre-start" yaml:"pre-start"` 16 | RawPostStart string `json:"post-start" yaml:"post-start"` 17 | RawPreStop string `json:"pre-stop" yaml:"pre-stop"` 18 | RawPostStop string `json:"post-stop" yaml:"post-stop"` 19 | // until we have a very long list, it's probably easier 20 | // to do 4 changes in that file for each new event than 21 | // using `go generate` 22 | } 23 | 24 | func (h *hooks) PreBuild() string { 25 | return expandEnv(h.RawPreBuild) 26 | } 27 | 28 | func (h *hooks) PostBuild() string { 29 | return expandEnv(h.RawPostBuild) 30 | } 31 | 32 | func (h *hooks) PreStart() string { 33 | return expandEnv(h.RawPreStart) 34 | } 35 | 36 | func (h *hooks) PostStart() string { 37 | return expandEnv(h.RawPostStart) 38 | } 39 | 40 | func (h *hooks) PreStop() string { 41 | return expandEnv(h.RawPreStop) 42 | } 43 | 44 | func (h *hooks) PostStop() string { 45 | return expandEnv(h.RawPostStop) 46 | } 47 | 48 | // Merge another set of hooks into the existing object. Existing 49 | // hooks will be overridden if the corresponding hooks from the 50 | // source struct are defined. Returns true if some content was 51 | // overiden in the process. 52 | func (h *hooks) CopyFrom(source hooks) (overridden bool) { 53 | overrideIfFromNotEmpty := func(from string, to *string) { 54 | if from != "" { 55 | overridden = overridden || *to != "" 56 | *to = from 57 | } 58 | } 59 | overrideIfFromNotEmpty(source.RawPreBuild, &h.RawPreBuild) 60 | overrideIfFromNotEmpty(source.RawPostBuild, &h.RawPostBuild) 61 | overrideIfFromNotEmpty(source.RawPreStart, &h.RawPreStart) 62 | overrideIfFromNotEmpty(source.RawPostStart, &h.RawPostStart) 63 | overrideIfFromNotEmpty(source.RawPreStop, &h.RawPreStop) 64 | overrideIfFromNotEmpty(source.RawPostStop, &h.RawPostStop) 65 | return 66 | } 67 | -------------------------------------------------------------------------------- /crane/hooks_test.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestCopyFromBehavior(t *testing.T) { 10 | target := hooks{ 11 | RawPreBuild: "from target", 12 | RawPostBuild: "from target", 13 | RawPreStart: "from target", 14 | RawPostStart: "from target", 15 | } 16 | source := hooks{ 17 | RawPreBuild: "from source", 18 | RawPreStart: "from source", 19 | } 20 | target.CopyFrom(source) 21 | assert.Equal(t, "from source", target.RawPreBuild, "Source hook should have precedence") 22 | assert.Equal(t, "from target", target.RawPostBuild, "Undefined hooks in target should not affect existing hooks") 23 | assert.Equal(t, "from source", target.RawPreStart, "Source hook should have precedence") 24 | assert.Equal(t, "from target", target.RawPostStart, "Undefined hooks in target should not affect existing hooks") 25 | } 26 | 27 | func TestCopyFromReturnValue(t *testing.T) { 28 | target := hooks{ 29 | RawPreStart: "foo", 30 | } 31 | source := hooks{ 32 | RawPostStart: "bar", 33 | } 34 | assert.False(t, target.CopyFrom(source), "Copying unrelated hooks should not trigger an override") 35 | target = hooks{ 36 | RawPreStart: "foo", 37 | } 38 | source = hooks{ 39 | RawPreStart: "bar", 40 | RawPostStart: "bar", 41 | } 42 | assert.True(t, target.CopyFrom(source), "Copying related hooks should trigger an override") 43 | } 44 | -------------------------------------------------------------------------------- /crane/logging_parameters.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | type LoggingParameters struct { 4 | RawDriver string `json:"driver" yaml:"driver"` 5 | RawOptions interface{} `json:"options" yaml:"options"` 6 | } 7 | 8 | func (l LoggingParameters) Options() []string { 9 | return sliceOrMap2ExpandedSlice(l.RawOptions) 10 | } 11 | 12 | func (l LoggingParameters) Driver() string { 13 | return expandEnv(l.RawDriver) 14 | } 15 | -------------------------------------------------------------------------------- /crane/network.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | type Network interface { 8 | Name() string 9 | Subnet() string 10 | ActualName() string 11 | Create() 12 | Exists() bool 13 | } 14 | 15 | type network struct { 16 | RawName string 17 | RawSubnet string `json:"subnet" yaml:"subnet"` 18 | } 19 | 20 | func (n *network) Name() string { 21 | return expandEnv(n.RawName) 22 | } 23 | 24 | func (n *network) Subnet() string { 25 | return expandEnv(n.RawSubnet) 26 | } 27 | 28 | func (n *network) ActualName() string { 29 | return cfg.Prefix() + n.Name() 30 | } 31 | 32 | func (n *network) Create() { 33 | printInfof("Creating network %s ...\n", n.ActualName()) 34 | 35 | args := []string{"network", "create"} 36 | 37 | if len(n.Subnet()) > 0 { 38 | args = append(args, "--subnet", n.Subnet()) 39 | } 40 | 41 | args = append(args, n.ActualName()) 42 | executeCommand("docker", args, os.Stdout, os.Stderr) 43 | } 44 | 45 | func (n *network) Exists() bool { 46 | args := []string{"network", "inspect", n.ActualName()} 47 | _, err := commandOutput("docker", args) 48 | return err == nil 49 | } 50 | -------------------------------------------------------------------------------- /crane/network_parameters.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import "fmt" 4 | 5 | type NetworkParameters struct { 6 | RawAlias interface{} `json:"alias" yaml:"alias"` 7 | RawAliases interface{} `json:"aliases" yaml:"aliases"` 8 | RawIp string `json:"ip" yaml:"ip"` 9 | RawIpv4Address string `json:"ipv4_address" yaml:"ipv4_address"` 10 | RawIp6 string `json:"ip6" yaml:"ip6"` 11 | RawIpv6Address string `json:"ipv6_address" yaml:"ipv6_address"` 12 | } 13 | 14 | // If aliases are not defined in the config, 15 | // the container name is added as a default alias. 16 | // When an empty array is configured, no default alias is used. 17 | func (n NetworkParameters) Alias(containerName string) []string { 18 | var aliases []string 19 | 20 | rawAliases := n.RawAliases 21 | if n.RawAlias != nil { 22 | rawAliases = n.RawAlias 23 | } 24 | 25 | if rawAliases == nil { 26 | aliases = append(aliases, containerName) 27 | } else { 28 | switch concreteValue := rawAliases.(type) { 29 | case []interface{}: 30 | for _, v := range concreteValue { 31 | aliases = append(aliases, expandEnv(v.(string))) 32 | } 33 | } 34 | } 35 | return aliases 36 | } 37 | 38 | func (n NetworkParameters) Ip() string { 39 | if len(n.RawIp) > 0 { 40 | return expandEnv(n.RawIp) 41 | } 42 | return expandEnv(n.RawIpv4Address) 43 | } 44 | 45 | func (n NetworkParameters) Ip6() string { 46 | if len(n.RawIp6) > 0 { 47 | return expandEnv(n.RawIp6) 48 | } 49 | return expandEnv(n.RawIpv6Address) 50 | } 51 | 52 | func createNetworkParemetersFromMap(val map[string]interface{}) NetworkParameters { 53 | var params NetworkParameters 54 | if val == nil { 55 | params = NetworkParameters{} 56 | } else { 57 | for k, v := range val { 58 | if k == "alias" || k == "aliases" { 59 | params.RawAlias = v.([]interface{}) 60 | } else if k == "ip" || k == "ipv4_address" { 61 | params.RawIp = v.(string) 62 | } else if k == "ip6" || k == "ipv6_address" { 63 | params.RawIp6 = v.(string) 64 | } else { 65 | panic(StatusError{fmt.Errorf("unknown network key: %v", k), 65}) 66 | } 67 | } 68 | } 69 | return params 70 | } 71 | -------------------------------------------------------------------------------- /crane/target.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | type Target struct { 8 | initial []string 9 | dependencies []string 10 | } 11 | 12 | // NewTarget receives the specified target 13 | // and determines which containers should be targeted. 14 | // The target might be extended to dependencies if --extend is given. 15 | // Additionally, the target is sorted alphabetically. 16 | func NewTarget(dependencyMap map[string]*Dependencies, targetArg string, extendFlag bool) (target Target, err error) { 17 | 18 | target = Target{ 19 | initial: []string{}, 20 | dependencies: []string{}, 21 | } 22 | 23 | initialTarget := cfg.ContainersForReference(targetArg) 24 | for _, c := range initialTarget { 25 | if includes(allowed, c) { 26 | target.initial = append(target.initial, c) 27 | } 28 | } 29 | 30 | if extendFlag { 31 | var ( 32 | dependenciesSet = make(map[string]struct{}) 33 | cascadingSeeds = []string{} 34 | ) 35 | // start from the explicitly targeted target 36 | for _, name := range target.initial { 37 | dependenciesSet[name] = struct{}{} 38 | cascadingSeeds = append(cascadingSeeds, name) 39 | } 40 | 41 | // Cascade until the dependency map has been fully traversed 42 | // according to the cascading flags. 43 | for len(cascadingSeeds) > 0 { 44 | nextCascadingSeeds := []string{} 45 | for _, seed := range cascadingSeeds { 46 | if dependencies, ok := dependencyMap[seed]; ok { 47 | // Queue direct dependencies if we haven't already considered them 48 | for _, name := range dependencies.All { 49 | if _, alreadyIncluded := dependenciesSet[name]; !alreadyIncluded { 50 | dependenciesSet[name] = struct{}{} 51 | nextCascadingSeeds = append(nextCascadingSeeds, name) 52 | } 53 | } 54 | } 55 | } 56 | cascadingSeeds = nextCascadingSeeds 57 | } 58 | 59 | for name := range dependenciesSet { 60 | if !includes(target.initial, name) { 61 | target.dependencies = append(target.dependencies, name) 62 | } 63 | } 64 | 65 | sort.Strings(target.dependencies) 66 | } 67 | 68 | return 69 | } 70 | 71 | // Return all targeted containers, sorted alphabetically 72 | func (t Target) all() []string { 73 | all := t.initial 74 | for _, name := range t.dependencies { 75 | all = append(all, name) 76 | } 77 | sort.Strings(all) 78 | return all 79 | } 80 | -------------------------------------------------------------------------------- /crane/target_test.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewTarget(t *testing.T) { 10 | defer func() { 11 | allowed = []string{} 12 | }() 13 | allowed = []string{"a", "b", "c"} 14 | containerMap := NewStubbedContainerMap(true, 15 | &container{RawName: "a", RawNet: "bridge", RawLink: []string{"b:b"}}, 16 | &container{RawName: "b", RawNet: "bridge", RawLink: []string{"c:c"}}, 17 | &container{RawName: "c", RawNet: "bridge"}, 18 | ) 19 | cfg = &config{containerMap: containerMap} 20 | dependencyMap := cfg.DependencyMap() 21 | 22 | examples := []struct { 23 | target string 24 | extend bool 25 | expected Target 26 | }{ 27 | { 28 | target: "a", 29 | extend: true, 30 | expected: Target{ 31 | initial: []string{"a"}, 32 | dependencies: []string{"b", "c"}, 33 | }, 34 | }, 35 | { 36 | target: "b", 37 | extend: true, 38 | expected: Target{ 39 | initial: []string{"b"}, 40 | dependencies: []string{"c"}, 41 | }, 42 | }, 43 | { 44 | target: "c", 45 | extend: false, 46 | expected: Target{ 47 | initial: []string{"c"}, 48 | dependencies: []string{}, 49 | }, 50 | }, 51 | { 52 | target: "b", 53 | extend: false, 54 | expected: Target{ 55 | initial: []string{"b"}, 56 | dependencies: []string{}, 57 | }, 58 | }, 59 | { 60 | target: "b", 61 | extend: true, 62 | expected: Target{ 63 | initial: []string{"b"}, 64 | dependencies: []string{"c"}, 65 | }, 66 | }, 67 | } 68 | 69 | for _, example := range examples { 70 | target, _ := NewTarget(dependencyMap, example.target, example.extend) 71 | assert.Equal(t, example.expected, target) 72 | } 73 | } 74 | 75 | func TestNewTargetNonExisting(t *testing.T) { 76 | defer func() { 77 | allowed = []string{} 78 | }() 79 | allowed = []string{"a", "b"} 80 | containerMap := NewStubbedContainerMap(false, 81 | &container{RawName: "a", RawNet: "bridge", RawLink: []string{"b:b"}}, 82 | &container{RawName: "b", RawNet: "bridge"}, 83 | ) 84 | 85 | cfg = &config{containerMap: containerMap} 86 | dependencyMap := cfg.DependencyMap() 87 | 88 | examples := []struct { 89 | target string 90 | extend bool 91 | expected Target 92 | }{ 93 | { 94 | target: "a", 95 | extend: true, 96 | expected: Target{ 97 | initial: []string{"a"}, 98 | dependencies: []string{"b"}, 99 | }, 100 | }, 101 | { 102 | target: "b", 103 | extend: false, 104 | expected: Target{ 105 | initial: []string{"b"}, 106 | dependencies: []string{}, 107 | }, 108 | }, 109 | } 110 | 111 | for _, example := range examples { 112 | target, _ := NewTarget(dependencyMap, example.target, example.extend) 113 | assert.Equal(t, example.expected, target) 114 | } 115 | } 116 | 117 | func TestDeduplicationAll(t *testing.T) { 118 | defer func() { 119 | allowed = []string{} 120 | }() 121 | allowed = []string{"a", "b", "c"} 122 | containerMap := NewStubbedContainerMap(true, 123 | &container{RawName: "a", RawNet: "bridge", RawLink: []string{"b:b"}}, 124 | &container{RawName: "b", RawNet: "bridge", RawLink: []string{"c:c"}}, 125 | &container{RawName: "c", RawNet: "bridge"}, 126 | ) 127 | groups := map[string][]string{ 128 | "ab": []string{"a", "b", "a"}, 129 | } 130 | cfg = &config{containerMap: containerMap, groups: groups} 131 | dependencyMap := cfg.DependencyMap() 132 | 133 | target, _ := NewTarget(dependencyMap, "ab", true) 134 | assert.Equal(t, []string{"a", "b", "c"}, target.all()) 135 | } 136 | -------------------------------------------------------------------------------- /crane/unit_of_work.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "text/template" 8 | ) 9 | 10 | type UnitOfWork struct { 11 | targeted []string 12 | containers []string 13 | order []string 14 | requireStarted []string 15 | } 16 | 17 | func NewUnitOfWork(dependencyMap map[string]*Dependencies, targeted []string) (uow *UnitOfWork, err error) { 18 | 19 | uow = &UnitOfWork{ 20 | targeted: targeted, 21 | containers: targeted, 22 | order: []string{}, 23 | requireStarted: []string{}, 24 | } 25 | 26 | // select all containers which we care about 27 | for { 28 | c := uow.containers 29 | initialLenContainers := len(c) 30 | for _, name := range c { 31 | dependencies := dependencyMap[name] 32 | if dependencies == nil { 33 | err = fmt.Errorf("Container %s referenced, but not defined.", name) 34 | return 35 | } 36 | for _, dep := range dependencies.All { 37 | uow.ensureInContainers(dep) 38 | if dependencies.requireStarted(dep) { 39 | uow.ensureInRequireStarted(dep) 40 | } 41 | } 42 | } 43 | if len(uow.containers) == initialLenContainers { 44 | break 45 | } 46 | } 47 | 48 | // bring containers into order 49 | for { 50 | initialLenOrdered := len(uow.order) 51 | for _, name := range uow.containers { 52 | if dependencies, ok := dependencyMap[name]; ok { 53 | if dependencies.satisfied() { 54 | uow.order = append(uow.order, name) 55 | delete(dependencyMap, name) 56 | for _, dependencies := range dependencyMap { 57 | dependencies.remove(name) 58 | } 59 | } 60 | } 61 | } 62 | if len(uow.order) == initialLenOrdered { 63 | break 64 | } 65 | } 66 | 67 | if len(uow.order) < len(uow.containers) { 68 | err = fmt.Errorf("Dependencies for container(s) %s could not be resolved.", uow.targeted) 69 | } else if len(uow.containers) == 0 { 70 | err = fmt.Errorf("Command cannot be applied to any container.") 71 | } 72 | 73 | return 74 | } 75 | 76 | func (uow *UnitOfWork) Run(cmds []string, detach bool) { 77 | uow.prepareRequirements() 78 | for _, container := range uow.Containers() { 79 | if includes(uow.targeted, container.Name()) { 80 | container.Run(cmds, true, detach) 81 | } else if includes(uow.requireStarted, container.Name()) || !container.Exists() { 82 | container.Start(false) 83 | } 84 | } 85 | } 86 | 87 | func (uow *UnitOfWork) Up(cmds []string, detach bool, noCache bool, parallel int) { 88 | uow.Targeted().Provision(noCache, parallel) 89 | uow.prepareRequirements() 90 | for _, container := range uow.Containers() { 91 | if includes(uow.targeted, container.Name()) { 92 | container.Run(cmds, true, detach) 93 | } else if includes(uow.requireStarted, container.Name()) || !container.Exists() { 94 | container.Start(false) 95 | } 96 | } 97 | } 98 | 99 | func (uow *UnitOfWork) Stats(noStream bool) { 100 | defaultArgs := []string{"stats"} 101 | if noStream { 102 | defaultArgs = append(defaultArgs, "--no-stream") 103 | } 104 | args := defaultArgs 105 | for _, container := range uow.Targeted() { 106 | if container.Running() { 107 | name := container.ActualName(false) 108 | args = append(args, name) 109 | } 110 | } 111 | if len(args) > len(defaultArgs) { 112 | executeCommand("docker", args, os.Stdout, os.Stderr) 113 | } else { 114 | printNoticef("None of the targeted container is running.\n") 115 | } 116 | } 117 | 118 | func (uow *UnitOfWork) Status(noTrunc bool) { 119 | uow.Targeted().Status(noTrunc) 120 | } 121 | 122 | // Push containers. 123 | func (uow *UnitOfWork) Push() { 124 | for _, container := range uow.Targeted() { 125 | container.Push() 126 | } 127 | } 128 | 129 | // Unpause containers. 130 | func (uow *UnitOfWork) Unpause() { 131 | for _, container := range uow.Targeted() { 132 | container.Unpause() 133 | } 134 | } 135 | 136 | // Pause containers. 137 | func (uow *UnitOfWork) Pause() { 138 | for _, container := range uow.Targeted().Reversed() { 139 | container.Pause() 140 | } 141 | } 142 | 143 | // Start containers. 144 | func (uow *UnitOfWork) Start() { 145 | uow.prepareRequirements() 146 | for _, container := range uow.Containers() { 147 | if includes(uow.targeted, container.Name()) { 148 | container.Start(true) 149 | } else if includes(uow.requireStarted, container.Name()) || !container.Exists() { 150 | container.Start(false) 151 | } 152 | } 153 | } 154 | 155 | // Stop containers. 156 | func (uow *UnitOfWork) Stop() { 157 | for _, container := range uow.Targeted().Reversed() { 158 | container.Stop() 159 | } 160 | } 161 | 162 | // Kill containers. 163 | func (uow *UnitOfWork) Kill() { 164 | for _, container := range uow.Targeted().Reversed() { 165 | container.Kill() 166 | } 167 | } 168 | 169 | func (uow *UnitOfWork) Exec(cmds []string, privileged bool, user string) { 170 | for _, container := range uow.Containers() { 171 | if includes(uow.targeted, container.Name()) { 172 | container.Exec(cmds, privileged, user) 173 | } else if includes(uow.requireStarted, container.Name()) || !container.Exists() { 174 | container.Start(false) 175 | } 176 | } 177 | } 178 | 179 | // Rm containers. 180 | func (uow *UnitOfWork) Rm(force bool, volumes bool) { 181 | for _, container := range uow.Targeted().Reversed() { 182 | container.Rm(force, volumes) 183 | } 184 | } 185 | 186 | // Create containers. 187 | func (uow *UnitOfWork) Create(cmds []string) { 188 | uow.prepareRequirements() 189 | for _, container := range uow.Containers() { 190 | if includes(uow.targeted, container.Name()) { 191 | container.Create(cmds) 192 | } else if includes(uow.requireStarted, container.Name()) || !container.Exists() { 193 | container.Start(false) 194 | } 195 | } 196 | } 197 | 198 | // Provision containers. 199 | func (uow *UnitOfWork) Provision(noCache bool, parallel int) { 200 | uow.Targeted().Provision(noCache, parallel) 201 | } 202 | 203 | // Pull containers. 204 | func (uow *UnitOfWork) PullImage() { 205 | for _, container := range uow.Targeted() { 206 | if len(container.BuildParams().Context()) == 0 { 207 | container.PullImage() 208 | } 209 | } 210 | } 211 | 212 | // Log containers. 213 | func (uow *UnitOfWork) Logs(follow bool, timestamps bool, tail string, colorize bool, since string) { 214 | uow.Targeted().Logs(follow, timestamps, tail, colorize, since) 215 | } 216 | 217 | // Generate files. 218 | func (uow *UnitOfWork) Generate(templateFile string, output string) { 219 | templateFileParts := strings.Split(templateFile, "/") 220 | templateName := templateFileParts[len(templateFileParts)-1] 221 | 222 | tmpl, err := template.New(templateName).ParseFiles(templateFile) 223 | if err != nil { 224 | printErrorf("ERROR: %s\n", err) 225 | return 226 | } 227 | 228 | executeTemplate := func(outputFile string, templateInfo interface{}) { 229 | writer := os.Stdout 230 | if len(outputFile) > 0 { 231 | writer, _ = os.Create(outputFile) 232 | } 233 | err = tmpl.Execute(writer, templateInfo) 234 | if err != nil { 235 | printErrorf("ERROR: %s\n", err) 236 | } 237 | } 238 | 239 | if strings.Contains(output, "%s") { 240 | for _, container := range uow.TargetedInfo() { 241 | executeTemplate(fmt.Sprintf(output, container.PrefixedName()), container) 242 | } 243 | } else { 244 | tmplInfo := struct { 245 | Containers []ContainerInfo 246 | }{ 247 | Containers: uow.TargetedInfo(), 248 | } 249 | executeTemplate(output, tmplInfo) 250 | } 251 | } 252 | 253 | func (uow *UnitOfWork) Containers() Containers { 254 | c := []Container{} 255 | for _, name := range uow.order { 256 | c = append(c, cfg.Container(name)) 257 | } 258 | return c 259 | } 260 | 261 | func (uow *UnitOfWork) Targeted() Containers { 262 | c := []Container{} 263 | for _, name := range uow.order { 264 | if includes(uow.targeted, name) { 265 | c = append(c, cfg.Container(name)) 266 | } 267 | } 268 | return c 269 | } 270 | 271 | func (uow *UnitOfWork) TargetedInfo() []ContainerInfo { 272 | c := []ContainerInfo{} 273 | for _, name := range uow.order { 274 | if includes(uow.targeted, name) { 275 | c = append([]ContainerInfo{cfg.ContainerInfo(name)}, c...) 276 | } 277 | } 278 | return c 279 | } 280 | 281 | func (uow *UnitOfWork) Associated() []string { 282 | c := []string{} 283 | for _, name := range uow.order { 284 | if !includes(uow.targeted, name) { 285 | c = append(c, name) 286 | } 287 | } 288 | return c 289 | } 290 | 291 | func (uow *UnitOfWork) RequiredNetworks() []string { 292 | required := []string{} 293 | networks := cfg.NetworkNames() 294 | if len(networks) == 0 { 295 | return required 296 | } 297 | for _, container := range uow.Containers() { 298 | net := container.Net() 299 | if includes(networks, net) && !includes(required, net) { 300 | required = append(required, net) 301 | } 302 | for name, _ := range container.Networks() { 303 | if includes(networks, name) && !includes(required, name) { 304 | required = append(required, name) 305 | } 306 | } 307 | } 308 | return required 309 | } 310 | 311 | func (uow *UnitOfWork) RequiredVolumes() []string { 312 | required := []string{} 313 | volumes := cfg.VolumeNames() 314 | if len(volumes) == 0 { 315 | return required 316 | } 317 | for _, container := range uow.Containers() { 318 | for _, volumeSource := range container.VolumeSources() { 319 | if includes(volumes, volumeSource) && !includes(required, volumeSource) { 320 | required = append(required, volumeSource) 321 | } 322 | } 323 | } 324 | return required 325 | } 326 | 327 | func (uow *UnitOfWork) ensureInContainers(name string) { 328 | if !includes(uow.containers, name) { 329 | uow.containers = append(uow.containers, name) 330 | } 331 | } 332 | 333 | func (uow *UnitOfWork) ensureInRequireStarted(name string) { 334 | if !includes(uow.requireStarted, name) { 335 | uow.requireStarted = append(uow.requireStarted, name) 336 | } 337 | } 338 | 339 | func (uow *UnitOfWork) prepareRequirements() { 340 | uow.prepareNetworks() 341 | uow.prepareVolumes() 342 | } 343 | 344 | func (uow *UnitOfWork) prepareNetworks() { 345 | for _, n := range uow.RequiredNetworks() { 346 | net := cfg.Network(n) 347 | if !net.Exists() { 348 | net.Create() 349 | } 350 | } 351 | } 352 | 353 | func (uow *UnitOfWork) prepareVolumes() { 354 | for _, v := range uow.RequiredVolumes() { 355 | vol := cfg.Volume(v) 356 | if !vol.Exists() { 357 | vol.Create() 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /crane/unit_of_work_test.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewUnitOfWork(t *testing.T) { 10 | 11 | examples := []struct { 12 | dependencyMap map[string]*Dependencies 13 | targeted []string 14 | expected *UnitOfWork 15 | err bool 16 | }{ 17 | { // resolvable map -> works 18 | dependencyMap: map[string]*Dependencies{ 19 | "b": &Dependencies{All: []string{"c"}}, 20 | "a": &Dependencies{All: []string{"b"}}, 21 | "c": &Dependencies{All: []string{}}, 22 | }, 23 | targeted: []string{"a", "b", "c"}, 24 | expected: &UnitOfWork{ 25 | targeted: []string{"a", "b", "c"}, 26 | containers: []string{"a", "b", "c"}, 27 | order: []string{"c", "b", "a"}, 28 | requireStarted: []string{}, 29 | }, 30 | err: false, 31 | }, 32 | { // cyclic map -> fails 33 | dependencyMap: map[string]*Dependencies{ 34 | "b": &Dependencies{All: []string{"c"}}, 35 | "a": &Dependencies{All: []string{"b"}}, 36 | "c": &Dependencies{All: []string{"a"}}, 37 | }, 38 | targeted: []string{"a", "b", "c"}, 39 | err: true, 40 | }, 41 | { // incomplete map -> fails 42 | dependencyMap: map[string]*Dependencies{ 43 | "a": &Dependencies{All: []string{"b"}}, 44 | }, 45 | targeted: []string{"a"}, 46 | err: true, 47 | }, 48 | { // partial target -> works 49 | dependencyMap: map[string]*Dependencies{ 50 | "b": &Dependencies{All: []string{"c"}, Link: []string{"c"}}, 51 | "a": &Dependencies{All: []string{"b"}}, 52 | "c": &Dependencies{All: []string{}}, 53 | }, 54 | targeted: []string{"a", "b"}, 55 | expected: &UnitOfWork{ 56 | targeted: []string{"a", "b"}, 57 | containers: []string{"a", "b", "c"}, 58 | order: []string{"c", "b", "a"}, 59 | requireStarted: []string{"c"}, 60 | }, 61 | err: false, 62 | }, 63 | } 64 | 65 | for _, example := range examples { 66 | uow, err := NewUnitOfWork(example.dependencyMap, example.targeted) 67 | if example.err { 68 | assert.Error(t, err) 69 | } else { 70 | if assert.NoError(t, err) { 71 | assert.Equal(t, example.expected, uow) 72 | } 73 | } 74 | } 75 | } 76 | 77 | func TestRequiredNetworks(t *testing.T) { 78 | var uow *UnitOfWork 79 | var networkMap map[string]Network 80 | 81 | // no networks 82 | cfg = &config{networkMap: networkMap} 83 | uow = &UnitOfWork{} 84 | assert.Equal(t, []string{}, uow.RequiredNetworks()) 85 | 86 | // some networks 87 | containerMap := NewStubbedContainerMap(true, 88 | &container{ 89 | RawName: "a", 90 | RawNet: "foo", 91 | }, 92 | &container{ 93 | RawName: "b", 94 | RawNet: "bar", 95 | }, 96 | &container{ 97 | RawName: "c", 98 | RawNet: "bar", 99 | }, 100 | ) 101 | networkMap = map[string]Network{ 102 | "foo": &network{RawName: "foo"}, 103 | "bar": &network{RawName: "bar"}, 104 | "baz": &network{RawName: "baz"}, 105 | } 106 | cfg = &config{containerMap: containerMap, networkMap: networkMap} 107 | uow = &UnitOfWork{order: []string{"a", "b", "c"}} 108 | assert.Equal(t, []string{"foo", "bar"}, uow.RequiredNetworks()) 109 | } 110 | 111 | func TestRequiredVolumes(t *testing.T) { 112 | var uow *UnitOfWork 113 | var volumeMap map[string]Volume 114 | 115 | // no volumes 116 | cfg = &config{volumeMap: volumeMap} 117 | uow = &UnitOfWork{} 118 | assert.Equal(t, []string{}, uow.RequiredVolumes()) 119 | 120 | // some volumes 121 | containerMap := NewStubbedContainerMap(true, 122 | &container{ 123 | RawName: "a", 124 | RawVolume: []string{"foo:/foo"}, 125 | }, 126 | &container{ 127 | RawName: "b", 128 | RawVolume: []string{"bar:/bar"}, 129 | }, 130 | &container{ 131 | RawName: "c", 132 | RawVolume: []string{"bar:/bar"}, 133 | }, 134 | ) 135 | volumeMap = map[string]Volume{ 136 | "foo": &volume{RawName: "foo"}, 137 | "bar": &volume{RawName: "bar"}, 138 | "baz": &volume{RawName: "baz"}, 139 | } 140 | cfg = &config{containerMap: containerMap, volumeMap: volumeMap} 141 | uow = &UnitOfWork{order: []string{"a", "b", "c"}} 142 | assert.Equal(t, []string{"foo", "bar"}, uow.RequiredVolumes()) 143 | } 144 | -------------------------------------------------------------------------------- /crane/version.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const Version = "3.6.1" 8 | 9 | func printVersion() { 10 | fmt.Printf("v%s\n", Version) 11 | } 12 | -------------------------------------------------------------------------------- /crane/volume.go: -------------------------------------------------------------------------------- 1 | package crane 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | type Volume interface { 8 | Name() string 9 | ActualName() string 10 | Create() 11 | Exists() bool 12 | } 13 | 14 | type volume struct { 15 | RawName string 16 | } 17 | 18 | func (v *volume) Name() string { 19 | return expandEnv(v.RawName) 20 | } 21 | 22 | func (v *volume) ActualName() string { 23 | return cfg.Prefix() + v.Name() 24 | } 25 | 26 | func (v *volume) Create() { 27 | printInfof("Creating volume %s ...\n", v.ActualName()) 28 | 29 | args := []string{"volume", "create", "--name", v.ActualName()} 30 | executeCommand("docker", args, os.Stdout, os.Stderr) 31 | } 32 | 33 | func (v *volume) Exists() bool { 34 | args := []string{"volume", "inspect", v.ActualName()} 35 | _, err := commandOutput("docker", args) 36 | return err == nil 37 | } 38 | -------------------------------------------------------------------------------- /docs/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/android-icon-144x144.png -------------------------------------------------------------------------------- /docs/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/android-icon-192x192.png -------------------------------------------------------------------------------- /docs/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/android-icon-36x36.png -------------------------------------------------------------------------------- /docs/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/android-icon-48x48.png -------------------------------------------------------------------------------- /docs/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/android-icon-72x72.png -------------------------------------------------------------------------------- /docs/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/android-icon-96x96.png -------------------------------------------------------------------------------- /docs/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-114x114.png -------------------------------------------------------------------------------- /docs/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-120x120.png -------------------------------------------------------------------------------- /docs/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-144x144.png -------------------------------------------------------------------------------- /docs/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-152x152.png -------------------------------------------------------------------------------- /docs/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-180x180.png -------------------------------------------------------------------------------- /docs/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-57x57.png -------------------------------------------------------------------------------- /docs/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-60x60.png -------------------------------------------------------------------------------- /docs/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-72x72.png -------------------------------------------------------------------------------- /docs/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-76x76.png -------------------------------------------------------------------------------- /docs/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon-precomposed.png -------------------------------------------------------------------------------- /docs/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/apple-icon.png -------------------------------------------------------------------------------- /docs/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /docs/docs-accelerated-mounts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 96 | Crane 97 | 98 | 108 | 109 | 110 | 111 |
112 |
113 |
114 | 115 | CRANE 116 |
117 |
118 | 132 | 133 |
134 |
135 |
136 |
137 |
138 | Crane is a Docker orchestration tool similar to Docker Compose with extra features.
139 | For example, it offers ultra-fast, dependency-free bind-mounts for Docker on Mac, with a speed boost of at least 10x! 140 |
141 |
142 |
143 |
144 |
145 |
146 | 179 | 180 |
181 |
182 | 183 |

Accelerated Mounts on Mac

184 | 185 |

Crane can optionally make use of Unison to have faster bind-mounts between the 186 | host and Docker for Mac. Example configuration:

187 | 188 |
189 |
services:
190 |   hello:
191 |     image: alpine
192 |     rm: true
193 |     interactive: true
194 |     tty: true
195 |     volume:
196 |       - "foo:/bar"
197 | accelerated-mounts:
198 |   hello:
199 |
200 | 201 |

When Crane is asked to run the hello service, it automatically sets up an accelerated mount for foo:/bar. Behind the scenes, Crane starts a sync container (which is using Unison internally) connected to a plain Docker volume, which is then mounted to /bar in the hello container. From a user perspective however, everything looks as if the host directory foo is directly bind-mounted to /bar in the container.

202 | 203 |

Accelerated mounts can either be specified using a service name (then all configured bind-mounts of this service are accelerated), or by specifying a specific bind mount. The example above is equivalent to:

204 | 205 |
206 |
accelerated-mounts:
207 |   "foo:/bar":
208 |
209 | 210 |

To reduce the amount of syncing that needs to be done, different containers or multiple instances of the same container 211 | definition share the same sync container and volume.

212 | 213 |

It is possible to customize each accelerated mount:

214 | 215 |
    216 |
  • uid/gid: Defaults to 0/0. Set this to the user/group ID the consuming 217 | container expects, e.g. 1000/1000.
  • 218 |
  • ignore: Defaults to 219 | Name {.git} and allows to exclude files / folders from the sync. See Ignoring Paths for possible values.
  • 220 |
  • flags: Defaults to 221 | ["-auto", "-batch", "-ignore='Name {.git}'", "-confirmbigdel=false", "-contactquietly", "-prefer=newer"], but can be overriden using anything that 222 | you can pass to unison, see its 223 | manual.
  • 224 |
225 | 226 |

To debug what is happening when the sync is not behaving as expected, you can use:
crane am logs -f hello. To remove the sync, use crane am reset hello.

227 | 228 | 231 | 232 |
233 |
234 |
235 | 236 |
237 |
238 |
239 | Copyright © 2020 Michael Sauter 240 |
241 |
242 |
243 | 244 | 245 | -------------------------------------------------------------------------------- /docs/docs-advanced.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 96 | Crane 97 | 98 | 108 | 109 | 110 | 111 |
112 |
113 |
114 | 115 | CRANE 116 |
117 |
118 | 132 | 133 |
134 |
135 |
136 |
137 |
138 | Crane is a Docker orchestration tool similar to Docker Compose with extra features.
139 | For example, it offers ultra-fast, dependency-free bind-mounts for Docker on Mac, with a speed boost of at least 10x! 140 |
141 |
142 |
143 |
144 |
145 |
146 | 179 | 180 |
181 |
182 | 183 |

Advanced Usage

184 | 185 |

Prefixing

186 | 187 |

By default, services, networks and volumes are prefixed with the project folder (the folder where the configuration file is located).

188 |

However, it is also possible to set a different prefix via the global CLI --prefix flag (or CRANE_PREFIX environment variable). The specified prefix is simply appended without any modification. A common use case for this feature is to launch a set of services 189 | in parallel, e.g. for CI builds.

190 | 193 |

Another option is to set a permanent prefix in the configuration itself. This can be done via the top-level setting prefix: "foo". It is also possible to disable prefixing altogether with prefix: false. 194 |

197 | 198 |

Hooks

199 | 200 |

In order to run certain commands before or after key lifecycle events of containers, hooks can be declared in the configuration. They are run synchronously on the host where Crane is installed, outside containers, via an exec call. They may interrupt the flow by returning a non-zero status. If shell features more advanced than basic variable expansion is required, you should explicitly spawn a shell to run the command in (sh -c 'ls *').

201 | 202 |

Hooks are declared at the top level of the configuration, under the hooks key. See YAML example below:

203 | 204 |
205 |
services:
206 |   service1:
207 |     image: busybox
208 |     detach: true
209 |     command: ["sleep", "50"]
210 |   service2:
211 |     image: busybox
212 |     detach: true
213 |     command: ["sleep", "50"]
214 |   service3:
215 |     image: busybox
216 |     detach: true
217 |     command: ["sleep", "50"]
218 | groups:
219 |   foo:
220 |     - service1
221 |     - service2
222 |   bar:
223 |     - service2
224 |     - service3
225 | hooks:
226 |   foo:
227 |     post-start: echo container from foo started
228 |   bar:
229 |     post-stop: echo container from bar stopped
230 |   service3:
231 |     post-stop: echo container service3 stopped
232 | 
233 |
234 | 235 |

Hooks can be defined on a group level (foo, bar) so that they apply to all services within that group, or directly on a container (service3). At most one hook can be registered per container and per event. When more than one hook is found for a given container and a given event, the following rules apply:

236 | 237 |
    238 |
  • Container-defined hooks have priority over group-defined ones, so in the example above, only "container service3 stopped" will be echoed when stopping service3.
  • 239 |
  • A fatal error will be raised at startup when 2 group-inherited hooks conflict. This is not the case in the previous example; even though foo and bar both contain service2, the hooks they declare are disjoint.
  • 240 |
241 | 242 |

The following hooks are currently available:

243 | 244 |
    245 |
  • pre-build: Executed before building an image
  • 246 |
  • post-build: Executed after building an image
  • 247 |
  • pre-start: Executed before starting or running a container
  • 248 |
  • post-start: Executed after starting or running a container
  • 249 |
  • pre-stop: Executed before stopping, killing or removing a running container
  • 250 |
  • post-stop: Executed after stopping, killing or removing a running container
  • 251 |
252 | 253 |

Every hook will have the name of the container for which this hook runs available as the environment variable CRANE_HOOKED_CONTAINER.

254 | 255 |

A typical example for a hook is waiting for some service to become available:

256 |
257 |
until `docker exec postgres-build psql -c 'select now()' 1>/dev/null 2>/dev/null`
258 |   do echo Waiting for Postgres to start ...
259 |   sleep 1
260 | done
261 |
262 | 263 |

Parallelism

264 | 265 |

By default, Crane executes all commands sequentially. However, you might want 266 | to increase the level of parallelism for network-heavy operations, in order to 267 | cut down the overall run time. The --parallel/-l flag allows you to 268 | specify the level of parallelism for commands where network can be a 269 | bottleneck (namely provision and up). Passing a value of 0 effectively 270 | disable throttling, which means that all provisioning will be done in parallel.

271 | 272 |

Override image tag

273 | 274 |

By using a the --tag flag, it is possible to globally overrides image tags. If 275 | you specify --tag 2.0-rc2, an image name repo/app:1.0 is treated as 276 | repo/app:2.0-rc2. The CRANE_TAG environment variable can also be used to 277 | set the global tag.

278 | 279 |

Generate command

280 | 281 |

The generate command can transform (part of) the configuration based on a 282 | given template, making it easy to re-use the configuation with other tools. 283 | --template is a required flag, which should point to a Go template. By 284 | default, the output is printed to STDOUT. It can also be written to a file using 285 | the --output flag. If the given filename contains %s, then multiple files 286 | are written (one per container), substituting %s with the name of the 287 | container. For each container, an object of type 288 | ContainerInfo 289 | is passed to the template. If one file is generated for all targeted containers, 290 | a list of containers is located under the key Containers. This feature is 291 | experimental, which means it can be changed or even removed in every minor 292 | version update.

293 | 294 | 298 | 299 |

YAML alias/merge

300 | 301 |

YAML gives you some advanced features like alias and merge. They allow you to easily avoid duplicated code in your crane.yml file. As a example, imagine you need to define 2 different services: web and admin. They share almost the same configuration except the cmd declaration. And imagine you also need 2 instances for each one for using with a node balancer. Then you can declare them as simply:

302 | 303 |
304 |
services:
305 |   web1: &web
306 |     image: my-web-app
307 |     link: ["db:db"]
308 |     ...
309 |     cmd: web
310 |   web2: *web
311 | 
312 |   admin1: &admin { <<: *web, command: admin }
313 |   admin2: *admin
314 | 
315 |
316 | 317 |

As a summary, &anchor declares the anchor property, *alias is the alias indicator to simply copy the mapping it references, and <<: *merge includes all the mapping but let you override some keys.

318 | 319 |

Variable Expansion

320 | 321 |

Basic environment variable expansion (${FOO}, $FOO) is supported throughout the configuration, but advanced shell features such as command substitution ($(cat foo), `cat foo`) or advanced expansions (sp{el,il,al}l, foo*, ~/project, $((A * B)), ${PARAMETER#PATTERN}) are not as the Docker CLI is called directly. Use $$ for escaping a raw $.

322 | 323 |

A use case for this is to pass an environment variable from the CLI to the container "at runtime". If your configuration contains env: ["FOO=$FOO"], then FOO=hello crane run ubuntu bash has access to $FOO with the value of hello.

324 | 325 |
326 |
327 |
328 | 329 |
330 |
331 |
332 | Copyright © 2020 Michael Sauter 333 |
334 |
335 |
336 | 337 | 338 | -------------------------------------------------------------------------------- /docs/docs-cli.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 96 | Crane 97 | 98 | 108 | 109 | 110 | 111 |
112 |
113 |
114 | 115 | CRANE 116 |
117 |
118 | 132 | 133 |
134 |
135 |
136 |
137 |
138 | Crane is a Docker orchestration tool similar to Docker Compose with extra features.
139 | For example, it offers ultra-fast, dependency-free bind-mounts for Docker on Mac, with a speed boost of at least 10x! 140 |
141 |
142 |
143 |
144 |
145 |
146 | 179 | 180 |
181 |
182 | 183 |

Command Line Interface

184 | 185 |

Crane is a very light wrapper around the Docker CLI. This means that most 186 | commands just call the corresponding Docker command, but for all targeted 187 | services. The basic format is crane <command> <target>, where <command> 188 | corresponds to a Docker command, and <target> either is a single container or 189 | a group.

190 | 191 |

When executing commands, keep the following 2 rules in mind:

192 | 193 |
    194 |
  1. Crane will apply the command ONLY to the target
  2. 195 |
  3. Crane will do with other services in the configuration whatever it takes in 196 | order for (1) to succeed
  4. 197 |
198 | 199 |

As an example, imagine you have a container web depending on container 200 | database. When you execute crane run web, then Crane will start database 201 | first, then run web (recreating web if it already exists).

202 | 203 |
204 |
usage: crane [<flags>] <command> [<args> ...]
205 | 
206 | Lift containers with ease - https://michaelsauter.github.io/crane
207 | 
208 | Flags:
209 |       --help                    Show context-sensitive help (also try
210 |                                 --help-long and --help-man).
211 |   -v, --verbose                 Enable verbose output.
212 |       --dry-run                 Dry run (implicitly verbose; no side effects).
213 |   -c, --config=~/crane.yml ...  Location of config file(s).
214 |   -p, --prefix=PREFIX           Container/Network/Volume prefix.
215 |   -x, --exclude=container|group ...  
216 |                                 Exclude group or container (repeatable).
217 |   -o, --only=container|group    Limit scope to group or container.
218 |   -e, --extend                  Extend command from target to dependencies.
219 |       --tag=TAG                 Override image tags.
220 | 
221 | Commands:
222 |   help [<command>...]
223 |     Show help.
224 | 
225 | 
226 |   up [<flags>] [<target>] [<cmd>...]
227 |     Build or pull images if they don't exist, then run or start the containers.
228 |     Alias of `lift`.
229 | 
230 |     -n, --no-cache    Build the image(s) without any cache.
231 |     -l, --parallel=1  Define how many containers are provisioned in parallel.
232 |     -d, --detach      Detach from targeted container.
233 | 
234 |   lift [<flags>] [<target>] [<cmd>...]
235 |     Build or pull images if they don't exist, then run or start the containers.
236 |     Alias of `up`.
237 | 
238 |     -n, --no-cache    Build the image(s) without any cache.
239 |     -l, --parallel=1  Define how many containers are provisioned in parallel.
240 |     -d, --detach      Detach from targeted container.
241 | 
242 |   run [<flags>] [<target>] [<cmd>...]
243 |     Run containers. Already existing containers will be removed first.
244 | 
245 |     -d, --detach  Detach from container.
246 | 
247 |   create [<target>] [<cmd>...]
248 |     Create containers. Already existing containers will be removed first.
249 | 
250 | 
251 |   start [<target>]
252 |     Start stopped containers. Non-existant containers will be created.
253 | 
254 | 
255 |   stop [<target>]
256 |     Stop running containers.
257 | 
258 | 
259 |   kill [<target>]
260 |     Kill running containers.
261 | 
262 | 
263 |   exec [<flags>] [<target>] [<cmd>...]
264 |     Execute command in the targeted container(s). Stopped containers will be
265 |     started, non-existant containers will be created first.
266 | 
267 |     --privileged  Give extended privileges to the process.
268 |     --user=USER   Run the command as this user.
269 | 
270 |   rm [<flags>] [<target>]
271 |     Remove stopped containers.
272 | 
273 |     -f, --force    Remove running containers, too.
274 |         --volumes  Remove volumes as well.
275 | 
276 |   pause [<target>]
277 |     Pause running containers.
278 | 
279 | 
280 |   unpause [<target>]
281 |     Unpause paused containers.
282 | 
283 | 
284 |   provision [<flags>] [<target>]
285 |     Build or pull images.
286 | 
287 |     -n, --no-cache    Build the image(s) without any cache.
288 |     -l, --parallel=1  Define how many containers are provisioned in parallel.
289 | 
290 |   pull [<target>]
291 |     Pull images.
292 | 
293 | 
294 |   push [<target>]
295 |     Push containers to the registry.
296 | 
297 | 
298 |   logs [<flags>] [<target>]
299 |     Show container logs.
300 | 
301 |     -f, --follow       Follow log output.
302 |         --tail=TAIL    Define number of lines to display at the end of the logs.
303 |     -t, --timestamps   Show timestamps.
304 |     -z, --colorize     Use different color for each container.
305 |         --since=SINCE  Show logs since timestamp.
306 | 
307 |   stats [<flags>] [<target>]
308 |     Display statistics about containers.
309 | 
310 |     -n, --no-stream  Disable stats streaming.
311 | 
312 |   status [<flags>] [<target>]
313 |     Display status of containers (similar to `docker ps`).
314 | 
315 |     -n, --no-trunc  Don't truncate output.
316 | 
317 |   cmd [<command>] [<arguments>...]
318 |     Execute predefined shortcut command.
319 | 
320 | 
321 |   generate [<flags>] [<target>]
322 |     Generate files by passing the config to given template.
323 | 
324 |     -t, --template=TEMPLATE  Template to use.
325 |     -O, --output=OUTPUT      The file(s) to write the output to.
326 | 
327 |   am reset [<target>]
328 |     Reset accelerated mount.
329 | 
330 | 
331 |   am logs [<flags>] [<target>]
332 |     Show logs of accelerated mount.
333 | 
334 |     -f, --follow  Follow log output.
335 | 
336 |   version [<flags>]
337 |     Display the current version.
338 | 
339 | 
340 | 
341 |
342 | 343 | 346 | 347 |

Adjusting targets

348 | 349 |

By default, Crane will apply the command ONLY to the target. For example, if you have a container web that depends on a container db, then executing crane run web will make sure that db is started before running web (recreating it if it already exists). If you want to extend the target - in this example to recreate db as well if it already exists - you can use --extend

350 | 351 |

If you want to exclude a container or a whole group from a Crane command, you 352 | can specify this with --exclude <reference> (or via CRANE_EXCLUDE). The 353 | flag can be repeated to exclude several services or groups (use a multi-line 354 | environment variable value to pass several references via CRANE_EXCLUDE). Excluded services' declaration and references in the configuration file 355 | will be completely ignored, so their dependencies will also be excluded 356 | (unless they are also required by other non-excluded services).

357 | 358 |

Apart from excluding services, it is also possible to limit the target to just 359 | one container or group with --only (or via CRANE_ONLY). The flag cannot be 360 | repeated. Containers outside the targets will not be considered by Crane then.

361 | 362 |

Ad hoc containers

363 | 364 |

If you append a command to up/lift or run, Crane will add a timestamp 365 | to the container name (e.g. foo will become foo-1447155694523), making it 366 | possible to have multiple containers based on the same Crane config.

367 | 368 | 371 | 372 |

Custom commands

373 | 374 |

Often you'll want to execute the same command inside a container over and over again. Crane allows to define shortcuts for them in the configuration, which can be executed via crane cmd. For example, you can define console: run web bin/rails c inside the commands section of the configuration to run an ad-hoc web container with the rails c command inside by simply executing crane cmd console from the host. This is effectively the same as running the longer crane run web bin/rails c.

375 | 376 |

The shortcuts can be defined as strings or arrays, and may start with any valid Crane subcommand. That means you can use them to run containers, or execute commands inside existing containers etc. The CLI interface accepts further arguments, which are simply appended to the predefined command. It is possible to list available commands by typing just crane cmd. Some examples:

377 | 378 |
379 |
commands:
380 |   console: run web bin/rails c
381 |   server: run web
382 |   rspec: run web bin/rspec
383 |   rubocop: run web bin/rubocop
384 |   psql: exec postgres psql
385 |
386 | 387 |

Usage is simple: crane cmd console would start a Rails console in the default (development) environment, and using crane cmd console test would append test to the predefined command, starting a console in the test environment.

388 | 389 |
390 |
391 |
392 | 393 |
394 |
395 |
396 | Copyright © 2020 Michael Sauter 397 |
398 |
399 |
400 | 401 | 402 | -------------------------------------------------------------------------------- /docs/docs-compatibility.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 96 | Crane 97 | 98 | 108 | 109 | 110 | 111 |
112 |
113 |
114 | 115 | CRANE 116 |
117 |
118 | 132 | 133 |
134 |
135 |
136 |
137 |
138 | Crane is a Docker orchestration tool similar to Docker Compose with extra features.
139 | For example, it offers ultra-fast, dependency-free bind-mounts for Docker on Mac, with a speed boost of at least 10x! 140 |
141 |
142 |
143 |
144 |
145 |
146 | 179 | 180 |
181 |
182 | 183 |

Compatibility Notes

184 | 185 |

While Crane can read Docker Compose configuration files (version 3), it differs in behaviour. Please make sure to read through the docs to compare. In addition, not all configuration options are fully supported yet:

186 | 187 |
    188 |
  • depends_on - accepts only array at the moment
  • 189 |
  • build - only accepts an object, not a string (see #327)
  • 190 |
191 | 192 |

Moreover, some options are not supported at all or don't make sense in the context of Crane, and will be ignored if present:

193 | 194 |
    195 |
  • domainname
  • 196 |
  • ulimits
  • 197 |
  • container_name
  • 198 |
  • deploy
  • 199 |
  • secrets
  • 200 |
201 | 202 | 203 |
204 |
205 |
206 | 207 |
208 |
209 |
210 | Copyright © 2020 Michael Sauter 211 |
212 |
213 |
214 | 215 | 216 | -------------------------------------------------------------------------------- /docs/docs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 96 | Crane 97 | 98 | 108 | 109 | 110 | 111 |
112 |
113 |
114 | 115 | CRANE 116 |
117 |
118 | 132 | 133 |
134 |
135 |
136 |
137 |
138 | Crane is a Docker orchestration tool similar to Docker Compose with extra features.
139 | For example, it offers ultra-fast, dependency-free bind-mounts for Docker on Mac, with a speed boost of at least 10x! 140 |
141 |
142 |
143 |
144 |
145 |
146 | 179 | 180 |
181 |
182 | 183 |

Command Line Interface

184 | 185 |

Crane is a very light wrapper around the Docker CLI. This means that most 186 | commands just call the corresponding Docker command, but for all targeted 187 | services. The basic format is crane <command> <target>, where <command> 188 | corresponds to a Docker command, and <target> either is a single container or 189 | a group.

190 | 191 |

When executing commands, keep the following 2 rules in mind:

192 | 193 |
    194 |
  1. Crane will apply the command ONLY to the target
  2. 195 |
  3. Crane will do with other services in the configuration whatever it takes in 196 | order for (1) to succeed
  4. 197 |
198 | 199 |

As an example, imagine you have a container web depending on container 200 | database. When you execute crane run web, then Crane will start database 201 | first, then run web (recreating web if it already exists).

202 | 203 |
204 |
usage: crane [<flags>] <command> [<args> ...]
205 | 
206 | Lift containers with ease - https://michaelsauter.github.io/crane
207 | 
208 | Flags:
209 |       --help                    Show context-sensitive help (also try
210 |                                 --help-long and --help-man).
211 |   -v, --verbose                 Enable verbose output.
212 |       --dry-run                 Dry run (implicitly verbose; no side effects).
213 |   -c, --config=~/crane.yml ...  Location of config file(s).
214 |   -p, --prefix=PREFIX           Container/Network/Volume prefix.
215 |   -x, --exclude=container|group ...  
216 |                                 Exclude group or container (repeatable).
217 |   -o, --only=container|group    Limit scope to group or container.
218 |   -e, --extend                  Extend command from target to dependencies.
219 |       --tag=TAG                 Override image tags.
220 | 
221 | Commands:
222 |   help [<command>...]
223 |     Show help.
224 | 
225 | 
226 |   up [<flags>] [<target>] [<cmd>...]
227 |     Build or pull images if they don't exist, then run or start the containers.
228 |     Alias of `lift`.
229 | 
230 |     -n, --no-cache    Build the image(s) without any cache.
231 |     -l, --parallel=1  Define how many containers are provisioned in parallel.
232 |     -d, --detach      Detach from targeted container.
233 | 
234 |   lift [<flags>] [<target>] [<cmd>...]
235 |     Build or pull images if they don't exist, then run or start the containers.
236 |     Alias of `up`.
237 | 
238 |     -n, --no-cache    Build the image(s) without any cache.
239 |     -l, --parallel=1  Define how many containers are provisioned in parallel.
240 |     -d, --detach      Detach from targeted container.
241 | 
242 |   run [<flags>] [<target>] [<cmd>...]
243 |     Run containers. Already existing containers will be removed first.
244 | 
245 |     -d, --detach  Detach from container.
246 | 
247 |   create [<target>] [<cmd>...]
248 |     Create containers. Already existing containers will be removed first.
249 | 
250 | 
251 |   start [<target>]
252 |     Start stopped containers. Non-existant containers will be created.
253 | 
254 | 
255 |   stop [<target>]
256 |     Stop running containers.
257 | 
258 | 
259 |   kill [<target>]
260 |     Kill running containers.
261 | 
262 | 
263 |   exec [<flags>] [<target>] [<cmd>...]
264 |     Execute command in the targeted container(s). Stopped containers will be
265 |     started, non-existant containers will be created first.
266 | 
267 |     --privileged  Give extended privileges to the process.
268 |     --user=USER   Run the command as this user.
269 | 
270 |   rm [<flags>] [<target>]
271 |     Remove stopped containers.
272 | 
273 |     -f, --force    Remove running containers, too.
274 |         --volumes  Remove volumes as well.
275 | 
276 |   pause [<target>]
277 |     Pause running containers.
278 | 
279 | 
280 |   unpause [<target>]
281 |     Unpause paused containers.
282 | 
283 | 
284 |   provision [<flags>] [<target>]
285 |     Build or pull images.
286 | 
287 |     -n, --no-cache    Build the image(s) without any cache.
288 |     -l, --parallel=1  Define how many containers are provisioned in parallel.
289 | 
290 |   pull [<target>]
291 |     Pull images.
292 | 
293 | 
294 |   push [<target>]
295 |     Push containers to the registry.
296 | 
297 | 
298 |   logs [<flags>] [<target>]
299 |     Show container logs.
300 | 
301 |     -f, --follow       Follow log output.
302 |         --tail=TAIL    Define number of lines to display at the end of the logs.
303 |     -t, --timestamps   Show timestamps.
304 |     -z, --colorize     Use different color for each container.
305 |         --since=SINCE  Show logs since timestamp.
306 | 
307 |   stats [<flags>] [<target>]
308 |     Display statistics about containers.
309 | 
310 |     -n, --no-stream  Disable stats streaming.
311 | 
312 |   status [<flags>] [<target>]
313 |     Display status of containers (similar to `docker ps`).
314 | 
315 |     -n, --no-trunc  Don't truncate output.
316 | 
317 |   cmd [<command>] [<arguments>...]
318 |     Execute predefined shortcut command.
319 | 
320 | 
321 |   generate [<flags>] [<target>]
322 |     Generate files by passing the config to given template.
323 | 
324 |     -t, --template=TEMPLATE  Template to use.
325 |     -O, --output=OUTPUT      The file(s) to write the output to.
326 | 
327 |   am reset [<target>]
328 |     Reset accelerated mount.
329 | 
330 | 
331 |   am logs [<flags>] [<target>]
332 |     Show logs of accelerated mount.
333 | 
334 |     -f, --follow  Follow log output.
335 | 
336 |   version [<flags>]
337 |     Display the current version.
338 | 
339 | 
340 | 
341 |
342 | 343 | 346 | 347 |

Adjusting targets

348 | 349 |

By default, Crane will apply the command ONLY to the target. For example, if you have a container web that depends on a container db, then executing crane run web will make sure that db is started before running web (recreating it if it already exists). If you want to extend the target - in this example to recreate db as well if it already exists - you can use --extend

350 | 351 |

If you want to exclude a container or a whole group from a Crane command, you 352 | can specify this with --exclude <reference> (or via CRANE_EXCLUDE). The 353 | flag can be repeated to exclude several services or groups (use a multi-line 354 | environment variable value to pass several references via CRANE_EXCLUDE). Excluded services' declaration and references in the configuration file 355 | will be completely ignored, so their dependencies will also be excluded 356 | (unless they are also required by other non-excluded services).

357 | 358 |

Apart from excluding services, it is also possible to limit the target to just 359 | one container or group with --only (or via CRANE_ONLY). The flag cannot be 360 | repeated. Containers outside the targets will not be considered by Crane then.

361 | 362 |

Ad hoc containers

363 | 364 |

If you append a command to up/lift or run, Crane will add a timestamp 365 | to the container name (e.g. foo will become foo-1447155694523), making it 366 | possible to have multiple containers based on the same Crane config.

367 | 368 | 371 | 372 |

Custom commands

373 | 374 |

Often you'll want to execute the same command inside a container over and over again. Crane allows to define shortcuts for them in the configuration, which can be executed via crane cmd. For example, you can define console: run web bin/rails c inside the commands section of the configuration to run an ad-hoc web container with the rails c command inside by simply executing crane cmd console from the host. This is effectively the same as running the longer crane run web bin/rails c.

375 | 376 |

The shortcuts can be defined as strings or arrays, and may start with any valid Crane subcommand. That means you can use them to run containers, or execute commands inside existing containers etc. The CLI interface accepts further arguments, which are simply appended to the predefined command. It is possible to list available commands by typing just crane cmd. Some examples:

377 | 378 |
379 |
commands:
380 |   console: run web bin/rails c
381 |   server: run web
382 |   rspec: run web bin/rspec
383 |   rubocop: run web bin/rubocop
384 |   psql: exec postgres psql
385 |
386 | 387 |

Usage is simple: crane cmd console would start a Rails console in the default (development) environment, and using crane cmd console test would append test to the predefined command, starting a console in the test environment.

388 | 389 |
390 |
391 |
392 | 393 |
394 |
395 |
396 | Copyright © 2020 Michael Sauter 397 |
398 |
399 |
400 | 401 | 402 | -------------------------------------------------------------------------------- /docs/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/favicon-16x16.png -------------------------------------------------------------------------------- /docs/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/favicon-32x32.png -------------------------------------------------------------------------------- /docs/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/favicon-96x96.png -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/favicon.ico -------------------------------------------------------------------------------- /docs/getting-started.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 94 | Crane 95 | 96 | 106 | 107 | 108 | 109 |
110 |
111 |
112 | 113 | CRANE 114 |
115 |
116 | 130 | 131 |
132 |
133 |
134 |
135 |
136 | Crane is a Docker orchestration tool similar to Docker Compose with extra features.
137 | For example, it offers ultra-fast, dependency-free bind-mounts for Docker on Mac, with a speed boost of at least 10x! 138 |
139 |
140 |
141 |
142 | 143 |

Welcome to Crane! You can either:

144 | 148 | 149 |

 

150 | 151 |

Existing docker-compose project

152 | 153 |

If you already have an existing project, it is easy to use Crane instead of docker-compose to get fast bind-mounts! In general, you only need to add a small snippet to tell Crane which folders should be accelerated.

154 | 155 |

In this example, we'll assume a Rails project with the following docker-compose.yml:

156 | 157 |
158 |
services:
159 |   postgres:
160 |     image: postgres:9.6.2
161 |     env:
162 |       - "POSTGRES_USER=example"
163 |       - "POSTGRES_PASSWORD=secret"
164 |       - "POSTGRES_DB=example_development"
165 |   web:
166 |     depends_on:
167 |       - "postgres"
168 |     build:
169 |       context: .
170 |     tty: true
171 |     stdin_open: true
172 |     ports:
173 |       - "3000:3000"
174 |     volumes:
175 |       - ".:/web"
176 |     command: "bin/rails s -b 0.0.0.0"
177 |
178 | 179 |

All you need to do is create a separate crane.yml in the same folder. Crane will automatically merge both config files together. The file should look like this:

180 | 181 |
182 |
accelerated-mounts:
183 |   web:
184 |     uid: 1000
185 |     gid: 1000
186 |
187 | 188 |

That's it. Next time you start the "web" service, it'll be fast! Try it out now: crane run example.

189 | 190 |

 

191 | 192 |

New project from scratch

193 | 194 |

We're going to walk through all steps required to setup a new Rails project, but if you use another language / framework, most things should be very similar and you can adapt as needed. For the purposes of this guide, let's assume we name our project "example", so we create a new folder example, which is where all files mentioned below are to be placed.

195 | 196 |

After you have installed Crane, let's start by creating a configuration file. Following is the Crane format (which strictly follows the flag names of docker run), but you can also use the docker-compose format. An example of that can be seen in the section about using an existing project with Crane.

197 | 198 |

This is how the crane.yml looks like:

199 |
200 |
services:
201 |   postgres:
202 |     image: postgres:9.6.2
203 |     env:
204 |       - "POSTGRES_USER=example"
205 |       - "POSTGRES_PASSWORD=secret"
206 |       - "POSTGRES_DB=example_development"
207 |   example:
208 |     requires: ["postgres"]
209 |     build:
210 |       context: .
211 |     publish: ["3000:3000"]
212 |     volume: [".:/example"]
213 |     tty: true
214 |     interactive: true
215 |     command: "bin/rails s -b 0.0.0.0"
216 | 
217 | accelerated-mounts:
218 |   example:
219 |     uid: 1000
220 |     gid: 1000
221 |
222 | 223 |

224 | We are using the official PostgreSQL image for the database and will build our own image for the example app. Let's write the Dockerfile for it: 225 |

226 | 227 |
228 |
FROM ruby:2.3.3
229 | 
230 | RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs
231 | 
232 | RUN useradd -m -s /bin/bash example; \
233 |     chgrp -R example /usr/local; \
234 |     find /usr/local -type d | xargs chmod g+w; \
235 |     mkdir -p /etc/sudoers.d; \
236 |     echo "example ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/example; \
237 |     chmod 0440 /etc/sudoers.d/example
238 | 
239 | ENV HOME /home/example
240 | 
241 | USER example
242 | 
243 | RUN gem install rails --no-ri --no-rdoc
244 | 
245 | WORKDIR /example
246 | 
247 | ENV BUNDLE_PATH /example/vendor/bundle
248 |
249 | 250 |

Now we are ready to build this image and create a new Rails project. Crane's up command can do both at the same time for us:

251 |
crane up example rails new . --force --database=postgresql --skip bundle
252 | 253 |

To configure our app to talk to the Postgres container, edit config/database.yml and uncomment password: secret and host: postgres under the development section. One last step is to install the dependencies:

254 | 255 |
crane run example bundle install
256 | 257 |

Finally, start the server with crane run example and check out the result at http://localhost:3000.

258 | 259 |
260 | 261 |
262 |
263 |
264 | Copyright © 2020 Michael Sauter 265 |
266 |
267 |
268 | 269 | 270 | -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/images/logo.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 94 | Crane 95 | 96 | 106 | 107 | 108 | 109 | 114 |
115 | 129 |
130 |
131 | 132 |

CRANE

133 | Lift containers with ease 134 |
135 |
136 |
137 |
138 |
139 |
140 | Crane is a Docker orchestration tool similar to Docker Compose with extra features.
141 | For example, it offers ultra-fast, dependency-free bind-mounts for Docker on Mac, with a speed boost of at least 10x! 142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
Docker Compose compatible
150 |

Crane reads docker-compose.yml files. Alternatively, you can use Crane's own configuration format, which maps more closely to Docker's run command to make it even easier. The CLI interface has similar commands than docker-compose, but they differ slightly in semantics. Read the compatibility notes for more information.

151 |
152 |
153 |
Better for development
154 |

Instead of detaching from all containers and interleaving the logs (like docker-compose up does), Crane attaches to the target and detaches from the dependencies by default. As a result, using interactive debugging is possible, e.g. if you are using Ruby, pry or byebug work as expected.

155 |
156 |
157 |
158 |
159 |
Groups
160 |

Instead of targeting all configured containers or a single one, Crane supports targeting a group of containers. This works by clustering containers in the configuration into groups.

161 |
162 |
163 |
Custom Commands
164 |

Pre-define commands that you run over and over again (like running a server, tests or REPLs) in the configuration. Now they are only a crane cmd away — a perfectly streamlined Docker workflow.

165 |
166 |
167 |
Hooks
168 |

Scripts can be run before or after key lifecycle events of containers. They are run synchronously on the host where Crane is installed and may even interrupt the flow by returning a non-zero status. Read more.

169 |
170 |
171 |
172 |
173 | 174 |
175 |
176 |
177 |
Accelerated Mounts with Docker for Mac
178 |

For example, booting a vanilla Rails app on a mid-2014 2,6GHz Macbook Pro:

179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 |
Default> 60s
With Docker :cached option> 15s
Using Crane~ 1.5s
195 |

Typically, the speed gains are even higher in real-world scenarios.

196 |
197 |
198 |
How do accelerated mounts work?
199 |

Crane integrates Unison, a file synchronizer. It is executed as a container, so you don't have to install and configure anything.

200 |

Every accelerated bind-mount is synced in both ways (unlike rsync!) via a Docker volume to the mounting container.

201 |

Crane does this automatically in the background and adjusts the container config on the fly - you shouldn't even notice! Except the speed of course ...

202 |
203 |
204 |
205 |
206 | 207 |
208 |
209 |
210 | Copyright © 2020 Michael Sauter 211 |
212 |
213 |
214 | 215 | 216 | -------------------------------------------------------------------------------- /docs/installation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 94 | Crane 95 | 96 | 106 | 107 | 108 | 109 |
110 |
111 |
112 | 113 | CRANE 114 |
115 |
116 | 130 | 131 |
132 |
133 |
134 |
135 |
136 | Crane is a Docker orchestration tool similar to Docker Compose with extra features.
137 | For example, it offers ultra-fast, dependency-free bind-mounts for Docker on Mac, with a speed boost of at least 10x! 138 |
139 |
140 |
141 |
142 |
143 |
144 | 145 | 146 |

Installation

147 | 148 |

The latest Crane release is 3.6.0 and requires Docker >= 1.13. Please have a look at the 149 | changelog 150 | when upgrading. 151 | 152 |

bash -c "`curl -sL https://raw.githubusercontent.com/michaelsauter/crane/v3.6.0/download.sh`" && \
153 | mv crane /usr/local/bin/crane
154 | 
155 |
156 |
157 |
158 |
159 | Copyright © 2020 Michael Sauter 160 |
161 |
162 |
163 | 164 | 165 | -------------------------------------------------------------------------------- /docs/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /docs/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/ms-icon-144x144.png -------------------------------------------------------------------------------- /docs/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/ms-icon-150x150.png -------------------------------------------------------------------------------- /docs/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/ms-icon-310x310.png -------------------------------------------------------------------------------- /docs/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelsauter/crane/3b3319fea8f0e03c754cf295f17d9f74cf79858b/docs/ms-icon-70x70.png -------------------------------------------------------------------------------- /docs/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Allow: / -------------------------------------------------------------------------------- /download.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Set version to latest unless set by user 4 | if [ -z "$VERSION" ]; then 5 | VERSION="3.6.1" 6 | fi 7 | 8 | echo "Downloading version ${VERSION}..." 9 | 10 | # OS information (contains e.g. darwin x86_64) 11 | UNAME=`uname -a | awk '{print tolower($0)}'` 12 | if [[ ($UNAME == *"mac os x"*) || ($UNAME == *darwin*) ]] 13 | then 14 | PLATFORM="darwin" 15 | else 16 | PLATFORM="linux" 17 | fi 18 | if [[ ($UNAME == *x86_64*) || ($UNAME == *amd64*) ]] 19 | then 20 | ARCH="amd64" 21 | else 22 | echo "Currently, there are no 32bit binaries provided." 23 | echo "You will need to build binaries yourself." 24 | exit 1 25 | fi 26 | 27 | # Download binary 28 | curl -L -o crane "https://github.com/michaelsauter/crane/releases/download/v${VERSION}/crane_${PLATFORM}_${ARCH}" 29 | 30 | # Make binary executable 31 | chmod +x crane 32 | 33 | echo "Done." 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/michaelsauter/crane/v3 2 | 3 | require ( 4 | github.com/alecthomas/kingpin v2.2.6+incompatible 5 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect 6 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect 7 | github.com/bjaglin/multiplexio v0.0.0-20141123221749-7477705f395a 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/fatih/color v1.7.0 10 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 11 | github.com/hashicorp/go-uuid v1.0.0 12 | github.com/imdario/mergo v0.3.6 13 | github.com/mattn/go-colorable v0.0.9 // indirect 14 | github.com/mattn/go-isatty v0.0.4 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | github.com/stretchr/testify v1.2.2 17 | gopkg.in/yaml.v2 v2.2.2 18 | ) 19 | 20 | go 1.16 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin v2.2.6+incompatible h1:5svnBTFgJjZvGKyYBtMB0+m5wvrbUHiqye8wRJMlnYI= 2 | github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE= 3 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= 4 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 5 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= 6 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 7 | github.com/bjaglin/multiplexio v0.0.0-20141123221749-7477705f395a h1:SdTYJG74QycLFZSWnYieT0quxf757HXn1YEllQ6hw9Y= 8 | github.com/bjaglin/multiplexio v0.0.0-20141123221749-7477705f395a/go.mod h1:rHKsfft9sTBxuwLLPx0I0pIaDQUSUbDO/6F8ogkU1y0= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 12 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 13 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= 14 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 15 | github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= 16 | github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 17 | github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= 18 | github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 19 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= 20 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 21 | github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= 22 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 26 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 29 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 30 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/michaelsauter/crane/v3/crane" 4 | 5 | func main() { 6 | crane.RealMain() 7 | } 8 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | 5 | version=$1 6 | 7 | if [ -z "$version" ]; then 8 | echo "No version passed! Example usage: ./release.sh 1.0.0" 9 | exit 1 10 | fi 11 | 12 | echo "Running tests..." 13 | crane cmd test 14 | 15 | echo "Update version..." 16 | old_version=$(grep -o "[0-9]*\.[0-9]*\.[0-9]*" crane/version.go) 17 | sed -i.bak 's/Version = "'$old_version'"/Version = "'$version'"/' crane/version.go 18 | rm crane/version.go.bak 19 | sed -i.bak 's/VERSION="'$old_version'"/VERSION="'$version'"/' download.sh 20 | rm download.sh.bak 21 | sed -i.bak 's/'$old_version'/'$version'/' README.md 22 | rm README.md.bak 23 | 24 | echo "Mark version as released in changelog..." 25 | today=$(date +'%Y-%m-%d') 26 | sed -i.bak 's/Unreleased/Unreleased\ 27 | \ 28 | ## '$version' ('$today')/' CHANGELOG.md 29 | rm CHANGELOG.md.bak 30 | 31 | echo "Update contributors..." 32 | git contributors | awk '{for (i=2; i CONTRIBUTORS 33 | 34 | echo "Build binaries..." 35 | crane cmd build 36 | 37 | echo "Update repository..." 38 | git add crane/version.go download.sh README.md CHANGELOG.md CONTRIBUTORS 39 | git commit -m "Bump version to ${version}" 40 | git tag --sign --message="v$version" --force "v$version" 41 | git tag --sign --message="latest" --force latest 42 | 43 | 44 | echo "v$version tagged." 45 | echo "Now, run 'git push origin master && git push --tags --force' and publish the release on GitHub." 46 | --------------------------------------------------------------------------------