├── .gitignore ├── Gopkg.toml ├── flags_test.go ├── .travis.yml ├── prompt.go ├── Gopkg.lock ├── file.go ├── .github └── workflows │ └── semgrep.yml ├── LICENSE ├── flags.go ├── config.go ├── Makefile ├── README.md ├── main.go ├── config_test.go ├── docker_test.go ├── marathon_test.go ├── marathon.go └── docker.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | [[constraint]] 2 | branch = "v2" 3 | name = "gopkg.in/yaml.v2" 4 | -------------------------------------------------------------------------------- /flags_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | ) 6 | 7 | var integration bool 8 | 9 | func init() { 10 | flag.BoolVar(&integration, "integration", false, "Run integration tests") 11 | flag.Parse() 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.8.x 5 | - 1.9.x 6 | 7 | cache: 8 | directories: 9 | - vendor 10 | 11 | script: 12 | - make setup 13 | - make lint 14 | - go test -v -race $(go list ./... | grep -v /vendor/) 15 | - make cover 16 | -------------------------------------------------------------------------------- /prompt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func promptConfirm(prompt string) bool { 8 | var s string 9 | for { 10 | fmt.Printf("%s (y/n): ", prompt) 11 | _, err := fmt.Scanln(&s) 12 | if err != nil { 13 | panic(err) 14 | } 15 | switch s { 16 | case "Yes", "yes", "y", "Y": 17 | return true 18 | case "No", "no", "n", "N": 19 | return false 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "v2" 6 | name = "gopkg.in/yaml.v2" 7 | packages = ["."] 8 | revision = "25c4ec802a7d637f88d584ab26798e94ad14c13b" 9 | 10 | [solve-meta] 11 | analyzer-name = "dep" 12 | analyzer-version = 1 13 | inputs-digest = "c39e9119cc91080f9178c39214d6ca06156205351dec2523319554ee3669537e" 14 | solver-name = "gps-cdcl" 15 | solver-version = 1 16 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "text/template" 7 | ) 8 | 9 | type fileVars struct { 10 | Images map[string]string 11 | } 12 | 13 | func fileLoad(path string, vars fileVars) ([]byte, error) { 14 | 15 | // Read file 16 | data, err := ioutil.ReadFile(path) // #nosec G304 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | // Create template from file 22 | tpl, err := template.New("file").Parse(string(data)) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | // Parse vars into template 28 | var buf bytes.Buffer 29 | err = tpl.Execute(&buf, vars) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return buf.Bytes(), nil 34 | 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | 2 | on: 3 | pull_request: {} 4 | workflow_dispatch: {} 5 | push: 6 | branches: 7 | - main 8 | - master 9 | schedule: 10 | - cron: '0 0 * * *' 11 | name: Semgrep config 12 | jobs: 13 | semgrep: 14 | name: semgrep/ci 15 | runs-on: ubuntu-20.04 16 | env: 17 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 18 | SEMGREP_URL: https://cloudflare.semgrep.dev 19 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 20 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 21 | container: 22 | image: returntocorp/semgrep 23 | steps: 24 | - uses: actions/checkout@v3 25 | - run: semgrep ci 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017 Cloudflare 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /flags.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | type flags struct { 12 | env string 13 | configFile string 14 | configPath string 15 | configDir string 16 | marathonHost string 17 | marathonCurlOpts string 18 | marathonForce bool 19 | skipPrompt bool 20 | verbose bool 21 | } 22 | 23 | func (f *flags) parse() (err error) { 24 | 25 | // Parse flags 26 | flag.StringVar(&f.env, "e", "", "Environment (e.g. \"prod\")") 27 | flag.StringVar(&f.configFile, "f", "deploy.yaml", "Config File") 28 | flag.StringVar(&f.marathonHost, "marathon.host", "", "Marathon Host (e.g. \"www.example.com\"") 29 | flag.StringVar(&f.marathonCurlOpts, "marathon.curlopts", "", "Marathon cURL options (e.g. '-H \"OauthEmail: no-reply@cloudflare.com\"'). Note: only -H is currently supported.") 30 | flag.BoolVar(&f.marathonForce, "marathon.force", false, "Add the ?force=true to the Marathon request") 31 | flag.BoolVar(&f.skipPrompt, "y", false, "Skip confirmation prompt") 32 | flag.BoolVar(&f.verbose, "v", false, "Verbose mode e.g. dump Marathon config") 33 | flag.Parse() 34 | 35 | // Validate flags 36 | if f.env == "" || f.configFile == "" { 37 | flag.Usage() 38 | os.Exit(1) 39 | } 40 | f.configPath, err = filepath.Abs(f.configFile) 41 | if err != nil { 42 | return fmt.Errorf("Error parsing config file path: %s", err) 43 | } 44 | _, err = os.Stat(f.configPath) 45 | if err != nil { 46 | return fmt.Errorf("Invalid config file path '%s': %s", f.configFile, err) 47 | } 48 | f.configDir = filepath.Dir(f.configPath) 49 | if f.marathonHost != "" && strings.Contains(f.marathonHost, "/") { 50 | return fmt.Errorf( 51 | "Marathon hostname cannot contain forward slash. Found: %s", 52 | f.marathonHost, 53 | ) 54 | } 55 | 56 | return 57 | 58 | } 59 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | yaml "gopkg.in/yaml.v2" 9 | ) 10 | 11 | type config struct { 12 | Marathon configMarathon `yaml:"marathon"` 13 | Image configImage `yaml:"image"` 14 | Environments map[string]configEnvironment `yaml:"environments"` 15 | } 16 | 17 | type configMarathon struct { 18 | Host string `yaml:"host"` 19 | Headers http.Header 20 | } 21 | 22 | type configImage struct { 23 | Repository string `yaml:"repository"` 24 | Name string `yaml:"name"` 25 | TagTemplate string `yaml:"tagTemplate"` 26 | } 27 | 28 | type configEnvironment struct { 29 | Marathon struct { 30 | File string `yaml:"file"` 31 | } `yaml:"marathon"` 32 | Images map[string]configImage 33 | } 34 | 35 | func configLoad(fileData []byte, flags flags) (config, error) { 36 | 37 | // Parse file YAML 38 | var c config 39 | err := yaml.Unmarshal(fileData, &c) 40 | if err != nil { 41 | return config{}, err 42 | } 43 | 44 | // Check environment exists 45 | if _, ok := c.Environments[flags.env]; !ok { 46 | return config{}, fmt.Errorf("Environment %s not found in config", flags.env) 47 | } 48 | 49 | // Override marathon host if provided 50 | if flags.marathonHost != "" { 51 | c.Marathon.Host = flags.marathonHost 52 | } 53 | 54 | // Parse marathon headers if provided 55 | if flags.marathonCurlOpts != "" { 56 | c.Marathon.Headers = http.Header{} 57 | curlOpts := strings.Split(flags.marathonCurlOpts, "-H") 58 | for _, curlOpt := range curlOpts { 59 | curlOpt = strings.TrimSpace(strings.Replace(curlOpt, "\"", "", 2)) 60 | if curlOpt == "" { 61 | continue 62 | } 63 | curlOptSplit := strings.Split(curlOpt, ": ") 64 | if len(curlOptSplit) == 2 { 65 | c.Marathon.Headers.Add(curlOptSplit[0], curlOptSplit[1]) 66 | } 67 | } 68 | } 69 | 70 | // Return config struct 71 | return c, nil 72 | 73 | } 74 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PATH:=$(GOPATH)/bin:$(PATH) 2 | 3 | .PHONY: help 4 | help: 5 | @echo 'Available commands:' 6 | @echo '* help - Show this message' 7 | @echo '* check - Check if required tools are installed' 8 | @echo '* setup - Install required tools and dependencies' 9 | @echo '* hooks - Install git hooks' 10 | @echo '* lint - Lint code' 11 | @echo '* cover - Coverage report' 12 | @echo '* test - Run tests' 13 | 14 | .PHONY: check 15 | check: 16 | ifeq ("","$(shell which go)") 17 | $(error go binary not in PATH) 18 | endif 19 | ifeq ("","$(GOPATH)") 20 | $(error GOPATH not configured correctly) 21 | endif 22 | @test -f $(GOPATH)/bin/gometalinter || \ 23 | echo "gometalinter binary not in $(GOPATH)/bin. run 'make setup'" 24 | @test -f $(GOPATH)/bin/dep || \ 25 | echo "dep binary not in $(GOPATH)/bin. run 'make setup'" 26 | 27 | .PHONY: setup 28 | setup: 29 | go get -u github.com/alecthomas/gometalinter 30 | gometalinter --install 31 | go get -u github.com/golang/dep/cmd/dep 32 | dep ensure 33 | 34 | .PHONY: hooks 35 | hooks: .git/hooks/pre-commit 36 | .git/hooks/pre-commit: 37 | echo "#!/bin/sh\nmake lint" > .git/hooks/pre-commit 38 | @chmod +x .git/hooks/pre-commit 39 | 40 | .PHONY: lint 41 | lint: check 42 | @gometalinter \ 43 | --disable-all \ 44 | --enable=maligned \ 45 | --enable=deadcode \ 46 | --enable=goconst \ 47 | --enable=goimports \ 48 | --enable=golint \ 49 | --enable=gosec \ 50 | --enable=gosimple \ 51 | --enable=ineffassign \ 52 | --enable=interfacer \ 53 | --enable=misspell \ 54 | --enable=safesql \ 55 | --enable=staticcheck \ 56 | --enable=structcheck \ 57 | --enable=unparam \ 58 | --enable=varcheck \ 59 | --enable=vet \ 60 | --skip vendor \ 61 | ./... 62 | 63 | .PHONY: cover 64 | cover: check 65 | @go test -cover $$(go list ./... | grep -v /vendor/) 66 | 67 | .PHONY: test 68 | test: check 69 | @go test -integration -v -race $$(go list ./... | grep -v /vendor/) 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Deployment Tool 2 | 3 | [![Build Status](https://travis-ci.org/cloudflare/cfdeploy.svg?branch=master)](https://travis-ci.org/cloudflare/cfdeploy) 4 | [![GoDoc](http://godoc.org/github.com/cloudflare/cfdeploy?status.svg)](http://godoc.org/github.com/cloudflare/cfdeploy) 5 | 6 | This tool allows you to easily deploy Docker Image(s) to Marathon. 7 | 8 | Features: 9 | 10 | * Has a single dependency - Go 11 | * Validates your Marathon YAML file and converts to JSON 12 | * Checks that your Docker images are published *before* deploying to Marathon 13 | * Automatically interpolates image tags via customizable template 14 | * e.g. `{{ .GitRevCount }}-{{ .GitRevShort }}` = `93-5814f5e` 15 | * Supports multiple deployment targets/environments 16 | 17 | ## Installation 18 | 19 | Assuming: 20 | 21 | 1. you have a correctly configured `$GOPATH` 22 | 2. you have `$GOPATH/bin` in your `$PATH` 23 | 24 | ``` 25 | go get -u github.com/cloudflare/cfdeploy 26 | cfdeploy # shows help message 27 | ``` 28 | 29 | ## Setup 30 | 31 | Create a `deploy.yaml` file alongside your Marathon `staging.yaml` and `prod.yaml` files such as this: 32 | 33 | ``` 34 | marathon: 35 | host: marathon.example.com 36 | image: 37 | repository: index.docker.io 38 | tagTemplate: "{{ .GitRevCount }}-{{ .GitRevShort }}" 39 | 40 | environments: 41 | prod: 42 | marathon: 43 | file: prod.yaml 44 | images: 45 | svc: 46 | name: library/hello-world 47 | staging: 48 | marathon: 49 | file: staging.yaml 50 | images: 51 | svc: 52 | name: library/hello-world 53 | ``` 54 | 55 | Then, modify your Marathon files to have the Docker image replaced into them, e.g: 56 | 57 | ``` 58 | ... 59 | container: 60 | type: DOCKER 61 | docker: 62 | image: {{ index .Images "svc" }} 63 | ... 64 | ``` 65 | 66 | Note: the key `"svc"` must match the key under `environments.ENV.images.KEY` in your `deploy.yaml` file. 67 | 68 | ## Usage 69 | 70 | If you have direct (unauthenticated) access to your Marathon instance: 71 | 72 | `cfdeploy -e staging` 73 | 74 | or 75 | 76 | `cfdeploy -e staging -y` to skip the confirmation prompt. 77 | 78 | If you need to specify a custom Marathon hostname or headers: 79 | 80 | ``` 81 | cfdeploy -e staging 82 | -marathon.host my-marathon.example.com \ 83 | -marathon.curlopts '-H "OauthEmail: ..." -H "OauthAccessToken: ..." -H "OauthExpires: ..."' 84 | ``` 85 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | ) 8 | 9 | func main() { 10 | 11 | // Parse & validate flags 12 | flags := flags{} 13 | if err := flags.parse(); err != nil { 14 | log.Fatalf(err.Error()) 15 | } 16 | 17 | // Read config file 18 | configData, err := ioutil.ReadFile(flags.configPath) 19 | if err != nil { 20 | log.Fatalf("Error reading config file '%s': %s\n", flags.configPath, err) 21 | } 22 | 23 | // Load config 24 | conf, err := configLoad(configData, flags) 25 | if err != nil { 26 | log.Fatalf("Error parsing config file: %s\n", err) 27 | } 28 | 29 | // Print config 30 | fmt.Printf("Environment: %s\n", flags.env) 31 | fmt.Printf("Config File: %s (%s)\n", flags.configFile, flags.configPath) 32 | 33 | // Get docker images and check they exist 34 | images, err := dockerImageList(conf, flags.env) 35 | if err != nil { 36 | log.Fatalf("Unable to verify docker images exists: %s\n", err) 37 | } 38 | vars := fileVars{Images: map[string]string{}} 39 | for key, image := range images { 40 | err := dockerCheckImage(image) 41 | if err != nil { 42 | log.Fatalf("Unable to verify docker images exists: %s\n", err) 43 | } 44 | vars.Images[key] = image.String() 45 | } 46 | 47 | // Print images 48 | fmt.Printf("Images:\n") 49 | for key, image := range vars.Images { 50 | fmt.Printf("* %s = %s\n", key, image) 51 | } 52 | 53 | // Check if deploy target is Marathon 54 | if conf.Marathon.Host != "" { 55 | 56 | // Prepare Marathon JSON config 57 | jsonConfig, err := marathonPrepare(flags, conf, vars) 58 | if err != nil { 59 | log.Fatalf("Error loading Marathon file: %s", err) 60 | } 61 | 62 | // Print info 63 | fmt.Printf( 64 | "Marathon File: %s\n", 65 | conf.Environments[flags.env].Marathon.File, 66 | ) 67 | fmt.Printf("Marathon URL: %s\n", marathonURL(conf, flags.marathonForce)) 68 | if len(conf.Marathon.Headers) > 0 { 69 | fmt.Printf("Marathon Headers:\n") 70 | for key, values := range conf.Marathon.Headers { 71 | for _, value := range values { 72 | if key == "Oauthaccesstoken" { 73 | value = "[hidden]" 74 | } 75 | fmt.Printf("* %s = %s\n", key, value) 76 | } 77 | } 78 | } 79 | if flags.verbose { 80 | fmt.Printf("Marathon Config: %s\n", jsonConfig) 81 | } 82 | 83 | // Confirm we should send request 84 | if !flags.skipPrompt && !promptConfirm("Deploy?") { 85 | log.Fatalf("Deployment cancelled") 86 | } 87 | 88 | // Deploy JSON config to Marathon 89 | result, err := marathonPush( 90 | conf, 91 | jsonConfig, 92 | flags.marathonForce, 93 | ) 94 | if err != nil { 95 | log.Fatalf("Marathon deploy error:\n%s\n", err) 96 | } 97 | log.Printf("Deployed to marathon:\n%+v\n", result) 98 | 99 | } else { 100 | log.Fatalf("Deploy target unknown. Valid options: Marathon\n") 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | const ConfigExample = ` 8 | marathon: 9 | host: www.example.com 10 | image: 11 | repository: index.docker.io 12 | tagTemplate: "{{ .GitRevCount }}-{{ .GitRevShort }}" 13 | 14 | environments: 15 | prod: 16 | marathon: 17 | file: prod.yaml 18 | images: 19 | hello: 20 | name: library/hello-world 21 | staging: 22 | marathon: 23 | file: staging.yaml 24 | images: 25 | hello: 26 | name: library/hello-world 27 | ` 28 | 29 | func TestConfigLoad(t *testing.T) { 30 | 31 | tests := []struct { 32 | Data string 33 | Flags flags 34 | ExpectMarathonHost string 35 | ExpectMarathonHeaders map[string]string 36 | ExpectError string 37 | }{ 38 | // Basic case 39 | { 40 | Data: ConfigExample, 41 | Flags: flags{ 42 | env: "prod", 43 | }, 44 | ExpectMarathonHost: "www.example.com", 45 | ExpectMarathonHeaders: map[string]string{}, 46 | }, 47 | // Override marathon host 48 | { 49 | Data: ConfigExample, 50 | Flags: flags{ 51 | env: "prod", 52 | marathonHost: "test.example.com", 53 | }, 54 | ExpectMarathonHost: "test.example.com", 55 | ExpectMarathonHeaders: map[string]string{}, 56 | }, 57 | // Override marathon curl opts with single header 58 | { 59 | Data: ConfigExample, 60 | Flags: flags{ 61 | env: "prod", 62 | marathonCurlOpts: "-H \"OauthEmail: no-reply@cloudflare.com\"", 63 | }, 64 | ExpectMarathonHost: "www.example.com", 65 | ExpectMarathonHeaders: map[string]string{ 66 | "OauthEmail": "no-reply@cloudflare.com", 67 | }, 68 | }, 69 | // Override marathon curl opts with multiple headers 70 | { 71 | Data: ConfigExample, 72 | Flags: flags{ 73 | env: "prod", 74 | marathonCurlOpts: "-H \"OauthEmail: no-reply@cloudflare.com\" -H \"OauthExpires: 1501617700\"", 75 | }, 76 | ExpectMarathonHost: "www.example.com", 77 | ExpectMarathonHeaders: map[string]string{ 78 | "OauthEmail": "no-reply@cloudflare.com", 79 | "OauthExpires": "1501617700", 80 | }, 81 | }, 82 | } 83 | 84 | for i, test := range tests { 85 | 86 | // Test loading 87 | config, err := configLoad( 88 | []byte(test.Data), 89 | test.Flags, 90 | ) 91 | if test.ExpectError != "" && err == nil { 92 | t.Errorf( 93 | "(%d) Expected error loading config YAML but did not get: %s", 94 | i, 95 | test.ExpectError, 96 | ) 97 | } else if test.ExpectError == "" && err != nil { 98 | t.Errorf("(%d) Unexpected error loading config YAML: %s", i, err) 99 | } else if err != nil && test.ExpectError != err.Error() { 100 | t.Errorf( 101 | "(%d) Unexpected error loading config YAML.\nExpected: %s\nGot: %s\n", 102 | i, 103 | test.ExpectError, 104 | err, 105 | ) 106 | } 107 | 108 | // Test marathon host override 109 | if config.Marathon.Host != test.ExpectMarathonHost { 110 | t.Errorf( 111 | "(%d) Error parsing config YAML. Expect marathon.host '%s', got '%s'", 112 | i, 113 | test.ExpectMarathonHost, 114 | config.Marathon.Host, 115 | ) 116 | } 117 | 118 | // Test marathon headers 119 | for hKey, hVal := range test.ExpectMarathonHeaders { 120 | got := config.Marathon.Headers.Get(hKey) 121 | if got != hVal { 122 | t.Errorf( 123 | "(%d) Error parsing Marathon headers. Expect '%s' = '%s', got '%s'", 124 | i, 125 | hKey, 126 | hVal, 127 | got, 128 | ) 129 | } 130 | } 131 | 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /docker_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestDockerTag(t *testing.T) { 9 | if !integration { 10 | t.Skip("Skipping git integration test") 11 | } 12 | tests := []struct { 13 | tagTemplate string 14 | }{ 15 | { 16 | tagTemplate: "{{ .GitBranch }}", 17 | }, 18 | { 19 | tagTemplate: "{{ .GitRevCount }}", 20 | }, 21 | { 22 | tagTemplate: "{{ .GitRevShort }}", 23 | }, 24 | } 25 | for i, test := range tests { 26 | tag, err := dockerTag(test.tagTemplate) 27 | if err != nil { 28 | t.Errorf("(%d) Unexpected error: %s", i, err) 29 | } 30 | if tag == "" { 31 | t.Errorf("(%d) Tag should not be empty", i) 32 | } 33 | if strings.Contains(tag, "\n") { 34 | t.Errorf("(%d) Tag should not contain newline characters", i) 35 | } 36 | } 37 | } 38 | 39 | func TestDockerImageList(t *testing.T) { 40 | if !integration { 41 | t.Skip("Skipping docker registry integration test") 42 | } 43 | c := config{ 44 | Image: configImage{ 45 | Repository: "index.docker.io", 46 | TagTemplate: "latest", 47 | }, 48 | Environments: map[string]configEnvironment{ 49 | "prod": configEnvironment{ 50 | Images: map[string]configImage{ 51 | "hello": configImage{ 52 | Name: "library/hello-world", 53 | }, 54 | }, 55 | }, 56 | }, 57 | } 58 | images, err := dockerImageList(c, "prod") 59 | if err != nil { 60 | t.Errorf("Unexpected error getting Docker image list: %s", err) 61 | } 62 | if len(images) != 1 { 63 | t.Fatalf("Expected image list length = 1, got: %d", len(images)) 64 | } 65 | if _, ok := images["hello"]; !ok { 66 | t.Fatalf( 67 | "Expected 1st image key = '%s'", 68 | "hello", 69 | ) 70 | } 71 | if images["hello"].Repository != "index.docker.io" { 72 | t.Errorf( 73 | "Expected 1st image repository = '%s', got: '%s", 74 | "index.docker.io", 75 | images["hello"].Repository, 76 | ) 77 | } 78 | if images["hello"].Name != "library/hello-world" { 79 | t.Errorf( 80 | "Expected 1st image name = '%s', got: '%s", 81 | "library/hello-world", 82 | images["hello"].Name, 83 | ) 84 | } 85 | if images["hello"].Tag != "latest" { 86 | t.Errorf( 87 | "Expected 1st image tag = '%s', got: '%s", 88 | "latest", 89 | images["hello"].Tag, 90 | ) 91 | } 92 | } 93 | 94 | func TestDockerCheckImage(t *testing.T) { 95 | tests := []struct { 96 | image dockerImage 97 | err string 98 | }{ 99 | // TODO: Find a secure docker registry that supports anonymous tokens 100 | // { 101 | // image: dockerImage{ 102 | // Repository: "index.docker.io", 103 | // Name: "secure-image", 104 | // Tag: "b65349dad81", 105 | // }, 106 | // err: "", 107 | // }, 108 | { 109 | image: dockerImage{ 110 | Repository: "index.docker.io", 111 | Name: "library/hello-world", 112 | Tag: "latest", 113 | }, 114 | err: "", 115 | }, 116 | { 117 | image: dockerImage{ 118 | Repository: "index.docker.io", 119 | Name: "library/hello-world", 120 | Tag: "this-tag-does-not-exist", 121 | }, 122 | err: "Docker image/tag (index.docker.io/library/hello-world:this-tag-does-not-exist) not found", 123 | }, 124 | { 125 | image: dockerImage{}, 126 | err: "Image repository cannot be blank", 127 | }, 128 | { 129 | image: dockerImage{ 130 | Repository: "index.docker.io", 131 | }, 132 | err: "Image name cannot be blank", 133 | }, 134 | { 135 | image: dockerImage{ 136 | Repository: "index.docker.io", 137 | Name: "library/hello-world", 138 | }, 139 | err: "Image tag cannot be blank", 140 | }, 141 | } 142 | for i, test := range tests { 143 | e := dockerCheckImage(test.image) 144 | if e != nil && test.err == "" { 145 | t.Errorf("(%d) Unexpected error: %s", i, e) 146 | } else if e == nil && test.err != "" { 147 | t.Errorf("(%d) Expected error '%s' but no error occurred", i, test.err) 148 | } else if e != nil && e.Error() != test.err { 149 | t.Errorf("(%d) Expected error '%s' but got '%s'", i, test.err, e) 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /marathon_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "testing" 7 | ) 8 | 9 | const marathonExampleYAML = ` 10 | id: /path/to/apps 11 | apps: 12 | - id: svc 13 | cpus: 0.2 14 | mem: 512 15 | instances: 1 16 | ports: 17 | - 0 18 | container: 19 | type: DOCKER 20 | docker: 21 | image: index.docker.io/library/hello-world 22 | network: HOST 23 | parameters: 24 | - key: log-driver 25 | value: journald 26 | volumes: 27 | - containerPath: /run/pald 28 | hostPath: /run/pald 29 | mode: RO 30 | env: 31 | SOME_ENV_VAR: some-env-var-value 32 | labels: 33 | some_label: some_label_value 34 | healthChecks: 35 | - protocol: HTTP 36 | path: /_healthcheck 37 | command: 38 | value: foo 39 | gracePeriodSeconds: 3 40 | intervalSeconds: 10 41 | portIndex: 0 42 | timeoutSeconds: 10 43 | maxConsecutiveFailures: 3 44 | ` 45 | 46 | func TestMarathonParseYAML(t *testing.T) { 47 | app, err := marathonParseYAML([]byte(marathonExampleYAML)) 48 | if err != nil { 49 | t.Errorf("Error parsing Marathon YAML: %s", err) 50 | } 51 | expect := "/path/to/apps" 52 | if app.ID != expect { 53 | t.Errorf( 54 | "Error parsing Marathon YAML. Expect id '%s', got '%s'", 55 | expect, 56 | app.ID, 57 | ) 58 | } 59 | } 60 | 61 | func TestMarathonValidate(t *testing.T) { 62 | exampleAppInvalid, _ := marathonParseYAML([]byte(marathonExampleYAML)) 63 | exampleAppValid, _ := marathonParseYAML([]byte(marathonExampleYAML)) 64 | exampleAppValid.Apps[0].Container.Docker.Image = "index.docker.io/library/hello-world:latest" 65 | tests := []struct { 66 | app marathonGroup 67 | err string 68 | }{ 69 | { 70 | app: exampleAppValid, 71 | err: "", 72 | }, 73 | { 74 | app: marathonGroup{}, 75 | err: "App id '' invalid", 76 | }, 77 | { 78 | app: exampleAppInvalid, 79 | err: "App 0 container docker image 'index.docker.io/library/hello-world' must have a tag", 80 | }, 81 | } 82 | for i, test := range tests { 83 | e := marathonValidate(test.app) 84 | if e != nil && test.err == "" { 85 | t.Errorf("(%d) Unexpected error: %s", i, e) 86 | } else if e == nil && test.err != "" { 87 | t.Errorf("(%d) Expected error '%s' but no error occurred", i, test.err) 88 | } else if e != nil && e.Error() != test.err { 89 | t.Errorf("(%d) Expected error '%s' but got '%s'", i, test.err, e) 90 | } 91 | } 92 | } 93 | 94 | func TestMarathonParseYAMLPorts(t *testing.T) { 95 | tests := []struct { 96 | yaml string 97 | expectError bool 98 | validateFunc func(*marathonGroup) bool 99 | }{ 100 | { 101 | yaml: "apps: [{ports: [0, 0]}]", 102 | validateFunc: func(g *marathonGroup) bool { return len(g.Apps[0].Ports) == 2 }, 103 | }, 104 | { 105 | yaml: "apps: [{portDefinitions: [{port: 0, name: metrics}, {port: 0, name: pprof}]}]", 106 | validateFunc: func(g *marathonGroup) bool { return g.Apps[0].PortDefinitions[0].Name == "metrics" }, 107 | }, 108 | { 109 | yaml: "apps: [{portDefinitions: [{port: 0}, {port: 0, name: pprof}]}]", 110 | validateFunc: func(g *marathonGroup) bool { 111 | return g.Apps[0].PortDefinitions[0].Name == "" && g.Apps[0].PortDefinitions[1].Name == "pprof" 112 | }, 113 | }, 114 | { 115 | yaml: "apps: [{portDefinitions: [0, 0]}]", 116 | expectError: true, 117 | validateFunc: nil, 118 | }, 119 | } 120 | for i, test := range tests { 121 | config, err := marathonParseYAML([]byte(test.yaml)) 122 | switch { 123 | case err != nil && test.expectError: 124 | case err == nil && test.expectError: 125 | t.Errorf("expected error, didn't get it") 126 | case err != nil: 127 | t.Errorf("unexpected error in test %v: %v", i, err) 128 | case !test.validateFunc(&config): 129 | t.Errorf("invalid result for test %v: %#v", i, config) 130 | } 131 | } 132 | } 133 | 134 | func TestMarathonYAMLtoJSON(t *testing.T) { 135 | tests := []struct { 136 | yaml []byte 137 | json []byte 138 | }{ 139 | { 140 | yaml: []byte(``), 141 | json: []byte(`{"id":""}`), 142 | }, 143 | { 144 | yaml: []byte(`apps: [{ports: [0, 0]}]`), 145 | json: []byte(`{"id":"","apps":[{"id":"","instances":0,"cpus":0,"mem":0,"constraints":null,"ports":[0,0],"requirePorts":false,"container":{"type":"","volumes":null}}]}`), 146 | }, 147 | // validate that portDefinition doesn't create empty name fields. 148 | { 149 | yaml: []byte(`apps: [{portDefinitions: [{port: 0}]}]`), 150 | json: []byte(`{"id":"","apps":[{"id":"","instances":0,"cpus":0,"mem":0,"constraints":null,"portDefinitions":[{"port":0}],"requirePorts":false,"container":{"type":"","volumes":null}}]}`), 151 | }, 152 | { 153 | yaml: []byte(`apps: [{portDefinitions: [{port: 0, name: metrics}, {port: 0, name: pprof}]}]`), 154 | json: []byte(`{"id":"","apps":[{"id":"","instances":0,"cpus":0,"mem":0,"constraints":null,"portDefinitions":[{"port":0,"name":"metrics"},{"port":0,"name":"pprof"}],"requirePorts":false,"container":{"type":"","volumes":null}}]}`), 155 | }, 156 | { 157 | yaml: []byte(`apps: [{healthChecks: [{protocol: COMMAND, command: {value: foo}, gracePeriodSeconds: 2, intervalSeconds: 2, portIndex: 1, timeoutSeconds: 2, maxConsecutiveFailures: 2 }]}]`), 158 | json: []byte(`{"id":"","apps":[{"id":"","instances":0,"cpus":0,"mem":0,"constraints":null,"requirePorts":false,"container":{"type":"","volumes":null},"healthChecks":[{"protocol":"COMMAND","command":{"value":"foo"},"gracePeriodSeconds":2,"intervalSeconds":2,"portIndex":1,"timeoutSeconds":2,"maxConsecutiveFailures":2}]}]}`), 159 | }, 160 | { 161 | yaml: []byte(`apps: [{healthChecks: [{protocol: HTTP, gracePeriodSeconds: 2, intervalSeconds: 2, portIndex: 1, timeoutSeconds: 2, maxConsecutiveFailures: 2 }]}]`), 162 | json: []byte(`{"id":"","apps":[{"id":"","instances":0,"cpus":0,"mem":0,"constraints":null,"requirePorts":false,"container":{"type":"","volumes":null},"healthChecks":[{"protocol":"HTTP","gracePeriodSeconds":2,"intervalSeconds":2,"portIndex":1,"timeoutSeconds":2,"maxConsecutiveFailures":2}]}]}`), 163 | }, 164 | } 165 | for i, test := range tests { 166 | config, err := marathonParseYAML(test.yaml) 167 | if err != nil { 168 | t.Fatalf("(%d) Unexpected error parsing YAML: %s", i, err) 169 | } 170 | marathonJSON, err := json.Marshal(config) 171 | if err != nil { 172 | t.Fatalf("(%d) Unexpected error marshaling JSON: %s", i, err) 173 | } 174 | if !bytes.Equal(marathonJSON, test.json) { 175 | t.Fatalf( 176 | "(%d) JSON mismatch.\nExpected:\t%s\nGot:\t\t%s\n", i, 177 | test.json, 178 | marathonJSON, 179 | ) 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /marathon.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | yaml "gopkg.in/yaml.v2" 13 | ) 14 | 15 | type marathonGroup struct { 16 | ID string `json:"id" yaml:"id"` 17 | Apps []marathonApp `json:"apps,omitempty" yaml:"apps"` 18 | Groups []marathonGroup `json:"groups,omitempty" yaml:"groups"` 19 | } 20 | 21 | type portDefinition struct { 22 | Port int64 `json:"port" yaml:"port"` 23 | Name string `json:"name,omitempty" yaml:"name,omitempty"` 24 | } 25 | 26 | type checkCommand struct { 27 | Value string `json:"value" yaml:"value"` 28 | } 29 | 30 | type marathonApp struct { 31 | ID string `json:"id" yaml:"id"` 32 | Cmd string `json:"cmd,omitempty" yaml:"cmd"` 33 | Args []string `json:"args,omitempty" yaml:"args"` 34 | Instances int64 `json:"instances" yaml:"instances"` 35 | CPUs float64 `json:"cpus" yaml:"cpus"` 36 | Mem int64 `json:"mem" yaml:"mem"` 37 | Disk int64 `json:"disk,omitempty" yaml:"disk"` 38 | Constraints [][]string `json:"constraints" yaml:"constraints"` 39 | Fetch []struct { 40 | URI string `json:"uri,omitempty" yaml:"uri"` 41 | Executable bool `json:"executable,omitempty" yaml:"executable"` 42 | Extract bool `json:"extract,omitempty" yaml:"extract"` 43 | Cache bool `json:"cache,omitempty" yaml:"cache"` 44 | } `json:"fetch,omitempty" yaml:"fetch"` 45 | URIs []string `json:"uris,omitempty" yaml:"uris"` 46 | StoreURLs []string `json:"storeUrls,omitempty" yaml:"storeUrls"` 47 | Ports []int64 `json:"ports,omitempty" yaml:"ports"` 48 | PortDefinitions []portDefinition `json:"portDefinitions,omitempty" yaml:"portDefinitions"` 49 | RequirePorts bool `json:"requirePorts" yaml:"requirePorts"` 50 | BackoffSeconds int64 `json:"backoffSeconds,omitempty" yaml:"backoffSeconds"` 51 | BackoffFactor float64 `json:"backoffFactor,omitempty" yaml:"backoffFactor"` 52 | MaxLaunchDelaySeconds int64 `json:"maxLaunchDelaySeconds,omitempty" yaml:"maxLaunchDelaySeconds"` 53 | TaskKillGracePeriodSeconds int64 `json:"taskKillGracePeriodSeconds,omitempty" yaml:"taskKillGracePeriodSeconds"` 54 | Container struct { 55 | Type string `json:"type" yaml:"type"` 56 | Volumes []struct { 57 | ContainerPath string `json:"containerPath" yaml:"containerPath"` 58 | HostPath string `json:"hostPath" yaml:"hostPath"` 59 | Mode string `json:"mode"` 60 | } `json:"volumes" yaml:"volumes"` 61 | Docker *struct { 62 | Image string `json:"image" yaml:"image"` 63 | Network string `json:"network" yaml:"network"` 64 | Parameters []struct { 65 | Key string `json:"key" yaml:"key"` 66 | Value string `json:"value" yaml:"value"` 67 | } `json:"parameters" yaml:"parameters"` 68 | Privileged bool `json:"privileged" yaml:"privileged"` 69 | ForcePullImage bool `json:"forcePullImage" yaml:"forcePullImage"` 70 | } `json:"docker,omitempty" yaml:"docker"` 71 | } `json:"container" yaml:"container"` 72 | Env map[string]string `json:"env,omitempty" yaml:"env"` 73 | Labels map[string]string `json:"labels,omitempty" yaml:"labels"` 74 | Dependencies []string `json:"dependencies,omitempty" yaml:"dependencies"` 75 | HealthChecks []struct { 76 | Protocol string `json:"protocol,omitempty" yaml:"protocol"` 77 | Command *checkCommand `json:"command,omitempty" yaml:"command"` 78 | Path string `json:"path,omitempty" yaml:"path"` 79 | GracePeriodSeconds int64 `json:"gracePeriodSeconds,omitempty" yaml:"gracePeriodSeconds"` 80 | IntervalSeconds int64 `json:"intervalSeconds,omitempty" yaml:"intervalSeconds"` 81 | PortIndex int64 `json:"portIndex,omitempty" yaml:"portIndex"` 82 | TimeoutSeconds int64 `json:"timeoutSeconds,omitempty" yaml:"timeoutSeconds"` 83 | MaxConsecutiveFailures int64 `json:"maxConsecutiveFailures,omitempty" yaml:"maxConsecutiveFailures"` 84 | } `json:"healthChecks,omitempty" yaml:"healthChecks"` 85 | UpgradeStrategy *struct { 86 | MinimumHealthCapacity *float64 `json:"minimumHealthCapacity,omitempty" yaml:"minimumHealthCapacity"` 87 | MaximumOverCapacity *float64 `json:"maximumOverCapacity,omitempty" yaml:"maximumOverCapacity"` 88 | } `json:"upgradeStrategy,omitempty" yaml:"upgradeStrategy"` 89 | IPAddress *struct { 90 | Groups []string `json:"groups,omitempty" yaml:"groups"` 91 | Labels map[string]string `json:"labels,omitempty" yaml:"labels"` 92 | NetworkName string `json:"networkName,omitempty" yaml:"networkName"` 93 | } `json:"ipAddress,omitempty" yaml:"ipAddress"` 94 | } 95 | 96 | type marathonResult struct { 97 | Message string `json:"message"` 98 | Details []struct { 99 | Path string `json:"path"` 100 | Errors []string `json:"errors"` 101 | } `json:"details"` 102 | Version string `json:"version"` 103 | DeploymentID string `json:"deploymentId"` 104 | } 105 | 106 | // marathonPrepare will read a YAML file, validate it and return a JSON 107 | func marathonPrepare(f flags, conf config, vars fileVars) ([]byte, error) { 108 | 109 | // Build file path 110 | filePath := f.configDir + "/" + conf.Environments[f.env].Marathon.File 111 | 112 | // Read file into a template and parse 113 | fileData, err := fileLoad(filePath, vars) 114 | if err != nil { 115 | return nil, fmt.Errorf( 116 | "Unable to load '%s' Marathon file:\n%s", 117 | conf.Environments[f.env].Marathon.File, 118 | err, 119 | ) 120 | } 121 | 122 | // Unmarshal YAML and validate 123 | group, err := marathonParseYAML(fileData) 124 | if err != nil { 125 | return nil, err 126 | } 127 | err = marathonValidate(group) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | // Marshal JSON 133 | marathonJSON, err := json.MarshalIndent(group, "", " ") 134 | if err != nil { 135 | return nil, fmt.Errorf( 136 | "Error marshaling JSON: %s", 137 | err, 138 | ) 139 | } 140 | 141 | return marathonJSON, nil 142 | 143 | } 144 | 145 | func marathonParseYAML(fileData []byte) (marathonGroup, error) { 146 | 147 | // Parse file YAML 148 | var group marathonGroup 149 | err := yaml.Unmarshal(fileData, &group) 150 | if err != nil { 151 | return marathonGroup{}, err 152 | } 153 | 154 | // Return group struct 155 | return group, nil 156 | 157 | } 158 | 159 | func marathonValidate(group marathonGroup) error { 160 | if group.ID == "" { 161 | return fmt.Errorf("App id '%s' invalid", group.ID) 162 | } 163 | for i, app := range group.Apps { 164 | if app.ID == "" { 165 | return fmt.Errorf( 166 | "App %d id invalid. Found: %s", 167 | i, 168 | app.ID, 169 | ) 170 | } 171 | if app.Container.Type != "DOCKER" { 172 | return fmt.Errorf( 173 | "App %d container type must be docker. Found: %s", 174 | i, 175 | app.Container.Type, 176 | ) 177 | } 178 | if app.Container.Docker.Image == "" { 179 | return fmt.Errorf( 180 | "App %d container docker image must not be empty", 181 | i, 182 | ) 183 | } 184 | if !strings.Contains(app.Container.Docker.Image, ":") { 185 | return fmt.Errorf( 186 | "App %d container docker image '%s' must have a tag", 187 | i, 188 | app.Container.Docker.Image, 189 | ) 190 | } 191 | } 192 | return nil 193 | } 194 | 195 | func marathonURL(conf config, force bool) string { 196 | url := fmt.Sprintf("https://%s/v2/groups", conf.Marathon.Host) 197 | if force { 198 | url = url + "?force=true" 199 | } 200 | 201 | return url 202 | } 203 | 204 | func marathonPush(conf config, jsonConfig []byte, force bool) (marathonResult, error) { 205 | // Prepare request 206 | u := marathonURL(conf, force) 207 | _, err := url.Parse(u) 208 | if err != nil { 209 | return marathonResult{}, fmt.Errorf( 210 | "Error parsing URL: %s", 211 | err, 212 | ) 213 | } 214 | req, err := http.NewRequest("PUT", u, bytes.NewBuffer(jsonConfig)) 215 | if err != nil { 216 | return marathonResult{}, fmt.Errorf( 217 | "Error building HTTP request: %s", 218 | err, 219 | ) 220 | } 221 | if len(conf.Marathon.Headers) > 0 { 222 | req.Header = conf.Marathon.Headers 223 | } 224 | req.Header.Set("Content-Type", "application/json") 225 | 226 | // Send request 227 | client := &http.Client{ 228 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 229 | return http.ErrUseLastResponse 230 | }, 231 | } 232 | resp, err := client.Do(req) 233 | if err != nil { 234 | return marathonResult{}, fmt.Errorf( 235 | "Error with PUT %s: %s", 236 | u, 237 | err, 238 | ) 239 | } 240 | // Get response 241 | respBody, err := ioutil.ReadAll(resp.Body) 242 | resp.Body.Close() // #nosec G104 243 | if err != nil { 244 | return marathonResult{}, fmt.Errorf( 245 | "Error reading response: %s", 246 | err, 247 | ) 248 | } 249 | // Parse response 250 | var result marathonResult 251 | if len(respBody) > 0 { 252 | err := json.Unmarshal(respBody, &result) 253 | if err != nil { 254 | return marathonResult{}, fmt.Errorf( 255 | "Error parsing response json: %s\nResponse:\n%s", 256 | err, 257 | respBody, 258 | ) 259 | } 260 | } 261 | // Check response status 262 | if resp.StatusCode == 302 { 263 | return marathonResult{}, fmt.Errorf( 264 | "%s. Location: %+v\nResult: %+v", 265 | resp.Status, 266 | resp.Header.Get("Location"), 267 | result, 268 | ) 269 | } else if resp.StatusCode == 422 { 270 | return marathonResult{}, fmt.Errorf( 271 | "%s\nResult: %+v\n\nConfig: %s", 272 | resp.Status, 273 | result, 274 | jsonConfig, 275 | ) 276 | } else if resp.StatusCode != 200 { 277 | return marathonResult{}, fmt.Errorf( 278 | "%s\nResult: %+v", 279 | resp.Status, 280 | result, 281 | ) 282 | } 283 | // Check result 284 | if result.DeploymentID == "" { 285 | return marathonResult{}, fmt.Errorf( 286 | "Deployment ID empty. Result: %+v", 287 | result, 288 | ) 289 | } 290 | return result, nil 291 | } 292 | -------------------------------------------------------------------------------- /docker.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "os/exec" 11 | "strings" 12 | "text/template" 13 | ) 14 | 15 | type dockerImage struct { 16 | Repository string 17 | Name string 18 | Tag string 19 | } 20 | 21 | func (i *dockerImage) Validate() error { 22 | if i.Repository == "" { 23 | return fmt.Errorf("Image repository cannot be blank") 24 | } 25 | if strings.Contains(i.Repository, "/") { 26 | return fmt.Errorf("Image repository cannot contain forward slashes") 27 | } 28 | if i.Name == "" { 29 | return fmt.Errorf("Image name cannot be blank") 30 | } 31 | if i.Name[0:1] == "/" { 32 | return fmt.Errorf("Image name cannot start with forward slash") 33 | } 34 | if i.Name[len(i.Name)-1:] == "/" { 35 | return fmt.Errorf("Image name cannot end with forward slash") 36 | } 37 | if i.Tag == "" { 38 | return fmt.Errorf("Image tag cannot be blank") 39 | } 40 | if strings.Contains(i.Tag, ":") { 41 | return fmt.Errorf("Image tag cannot contain colon") 42 | } 43 | return nil 44 | } 45 | 46 | func (i *dockerImage) String() string { 47 | return i.Repository + "/" + i.Name + ":" + i.Tag 48 | } 49 | 50 | var dockerTagVars struct { 51 | GitBranch string 52 | GitRevCount string 53 | GitRevShort string 54 | } 55 | 56 | // dockerTag renders a tag template 57 | func dockerTag(tagTemplate string) (string, error) { 58 | // Lazy load git vars 59 | var err error 60 | var out []byte 61 | if strings.Contains(tagTemplate, ".GitBranch") && dockerTagVars.GitBranch == "" { 62 | /* #nosec */ 63 | out, err = exec.Command("git", "symbolic-ref", "--short", "HEAD").Output() 64 | if err != nil { 65 | return "", err 66 | } 67 | dockerTagVars.GitBranch = strings.TrimSpace(string(out)) 68 | } 69 | if strings.Contains(tagTemplate, ".GitRevCount") && dockerTagVars.GitRevCount == "" { 70 | /* #nosec */ 71 | out, err = exec.Command("git", "rev-list", "--count", "HEAD").Output() 72 | if err != nil { 73 | return "", err 74 | } 75 | dockerTagVars.GitRevCount = strings.TrimSpace(string(out)) 76 | } 77 | if strings.Contains(tagTemplate, ".GitRevShort") && dockerTagVars.GitRevShort == "" { 78 | /* #nosec */ 79 | out, err = exec.Command("git", "rev-parse", "--short", "HEAD").Output() 80 | if err != nil { 81 | return "", err 82 | } 83 | dockerTagVars.GitRevShort = strings.TrimSpace(string(out)) 84 | } 85 | // Render template 86 | t := template.Must(template.New("tagTemplate").Parse(tagTemplate)) 87 | var buf bytes.Buffer 88 | err = t.Execute(&buf, dockerTagVars) 89 | if err != nil { 90 | return "", err 91 | } 92 | return buf.String(), nil 93 | } 94 | 95 | // dockerImageList compiles a list of dockerImage's from a given config struct 96 | func dockerImageList(c config, e string) (images map[string]dockerImage, err error) { 97 | images = map[string]dockerImage{} 98 | for imageKey, envImage := range c.Environments[e].Images { 99 | image := dockerImage{} 100 | // Add repository 101 | if envImage.Repository != "" { 102 | image.Repository = envImage.Repository 103 | } else if c.Image.Repository != "" { 104 | image.Repository = c.Image.Repository 105 | } else { 106 | err = fmt.Errorf( 107 | "Could not find image repository in config for %s", 108 | imageKey, 109 | ) 110 | return 111 | } 112 | // Add name 113 | if envImage.Name != "" { 114 | image.Name = envImage.Name 115 | } else if c.Image.Name != "" { 116 | image.Name = c.Image.Name 117 | } else { 118 | err = fmt.Errorf( 119 | "Could not find image name in config for %s", 120 | imageKey, 121 | ) 122 | return 123 | } 124 | // Add tag 125 | if envImage.TagTemplate != "" { 126 | image.Tag, err = dockerTag(envImage.TagTemplate) 127 | } else if c.Image.TagTemplate != "" { 128 | image.Tag, err = dockerTag(c.Image.TagTemplate) 129 | } else { 130 | err = fmt.Errorf( 131 | "Could not find image tag in config for %s", 132 | imageKey, 133 | ) 134 | } 135 | if err != nil { 136 | return 137 | } 138 | // Append image 139 | images[imageKey] = image 140 | } 141 | return 142 | } 143 | 144 | func dockerCheckImage(image dockerImage) error { 145 | 146 | // Validate image fields 147 | err := image.Validate() 148 | if err != nil { 149 | return err 150 | } 151 | 152 | // Attempt to verify image exists without auth 153 | authHeader, err := dockerGetImage(image.Repository, image.Name, image.Tag, "") 154 | if err != nil { 155 | return err 156 | } 157 | 158 | // Return if auth not required 159 | if authHeader == "" { 160 | return nil 161 | } 162 | 163 | // Auth required, so get a signed token (with grant) 164 | var authToken string 165 | authToken, err = dockerGetToken(authHeader) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | // Verify image exists with auth 171 | _, err = dockerGetImage(image.Repository, image.Name, image.Tag, authToken) 172 | return err 173 | 174 | } 175 | 176 | // dockerGetImage will query the image manifest to verify an image exists. 177 | // if the response is a 401 and contains a Www-Authenticate header, 178 | // it will be returned in authHeader. if an error occurs, err will be returned. 179 | // if the image is found, authHeader and err will be empty. 180 | func dockerGetImage(imageRepo, imageName, imageTag, token string) (authHeader string, err error) { 181 | // Build registry URL for image/tag 182 | url := fmt.Sprintf( 183 | "https://%s/v2/%s/manifests/%s", 184 | imageRepo, 185 | imageName, 186 | imageTag, 187 | ) 188 | // Build request 189 | req, err := http.NewRequest("GET", url, nil) 190 | if err != nil { 191 | err = fmt.Errorf("Error building request: %s", err) 192 | return 193 | } 194 | req.Header = http.Header{ 195 | "Content-Type": []string{ 196 | "application/json; charset=utf-8", 197 | }, 198 | } 199 | if token != "" { 200 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) 201 | } 202 | // Make request 203 | client := http.Client{} 204 | resp, err := client.Do(req) 205 | if err != nil { 206 | return "", err 207 | } 208 | // Check if auth is required 209 | if resp.StatusCode == 401 { 210 | if token != "" { 211 | err = fmt.Errorf( 212 | "HTTP response should not be 401 when token is provided", 213 | ) 214 | return 215 | } 216 | // Get WWW-Authenticate header 217 | authHeader = resp.Header.Get("Www-Authenticate") 218 | if authHeader == "" { 219 | err = fmt.Errorf( 220 | "Expected 401 response to contain Www-Authenticate error", 221 | ) 222 | } 223 | return 224 | } 225 | // Check if image/tag not found 226 | if resp.StatusCode == 404 { 227 | err = fmt.Errorf( 228 | "Docker image/tag (%s/%s:%s) not found", 229 | imageRepo, 230 | imageName, 231 | imageTag, 232 | ) 233 | return 234 | } 235 | // Check request was valid 236 | if resp.StatusCode != 200 { 237 | err = fmt.Errorf( 238 | "Response code not 200, got: %d", 239 | resp.StatusCode, 240 | ) 241 | return 242 | } 243 | // Get response 244 | respBody, err := ioutil.ReadAll(resp.Body) 245 | resp.Body.Close() // #nosec G104 246 | if err != nil { 247 | err = fmt.Errorf( 248 | "Error reading response: %s", 249 | err, 250 | ) 251 | return 252 | } 253 | // Parse response 254 | var searchResult struct { 255 | Name string 256 | Tag string 257 | Error struct { 258 | Code string 259 | Message string 260 | Detail struct { 261 | Type string 262 | Name string 263 | Action string 264 | } 265 | } 266 | } 267 | err = json.Unmarshal(respBody, &searchResult) 268 | if err != nil { 269 | err = fmt.Errorf("Error parsing response json: %s", err) 270 | return 271 | } 272 | // Check response values 273 | if searchResult.Error.Code != "" { 274 | err = fmt.Errorf( 275 | "GET %s\nRegistry search error (%s): %s", 276 | url, 277 | searchResult.Error.Code, 278 | searchResult.Error.Message, 279 | ) 280 | return 281 | } 282 | if searchResult.Name == "" || searchResult.Tag == "" { 283 | err = fmt.Errorf( 284 | "GET %s\nImage name/tag invalid: %+v", 285 | url, 286 | searchResult, 287 | ) 288 | } 289 | return 290 | } 291 | 292 | // dockerGetToken will get a secure Docker registry token 293 | func dockerGetToken(authHeader string) (string, error) { 294 | // Extract auth realm/service/scope from auth header 295 | var realm, service, scope string 296 | wwwAuthTrimmed := strings.TrimPrefix(authHeader, "Bearer ") 297 | wwwAuthSplit := strings.Split(string(wwwAuthTrimmed), ",") 298 | for _, wwwAuthPart := range wwwAuthSplit { 299 | wwwAuth := strings.Trim(wwwAuthPart, " ") 300 | if wwwAuth == "" { 301 | continue 302 | } 303 | wwwAuthSplit := strings.Split(wwwAuth, "=") 304 | if len(wwwAuthSplit) != 2 { 305 | continue 306 | } 307 | value := strings.TrimSpace(strings.Trim(wwwAuthSplit[1], "\"")) 308 | switch wwwAuthSplit[0] { 309 | case "realm": 310 | realm = value 311 | case "service": 312 | service = value 313 | case "scope": 314 | scope = value 315 | } 316 | } 317 | if realm == "" || service == "" || scope == "" { 318 | return "", fmt.Errorf( 319 | "Realm, service or scope empty (realm: '%s', service: '%s', scope '%s')", 320 | realm, 321 | service, 322 | scope, 323 | ) 324 | } 325 | // Build auth URL 326 | reqURL, err := url.Parse(realm) 327 | if err != nil { 328 | return "", fmt.Errorf( 329 | "Error parsing realm URL: %s", 330 | err, 331 | ) 332 | } 333 | reqQuery := url.Values{} 334 | reqQuery.Add("service", service) 335 | reqQuery.Add("scope", scope) 336 | reqURL.RawQuery = reqQuery.Encode() 337 | authURL := reqURL.String() 338 | // Request auth token 339 | resp, err := http.Get(authURL) // #nosec G107 340 | if err != nil { 341 | return "", fmt.Errorf( 342 | "GET %s\n%s", 343 | authURL, 344 | err, 345 | ) 346 | } 347 | // Get response 348 | respBody, err := ioutil.ReadAll(resp.Body) 349 | resp.Body.Close() // #nosec G104 350 | if err != nil { 351 | return "", fmt.Errorf( 352 | "GET %s\nError reading response: %s", 353 | authURL, 354 | err, 355 | ) 356 | } 357 | // Parse response 358 | var respObject struct { 359 | Token string 360 | } 361 | err = json.Unmarshal(respBody, &respObject) 362 | if err != nil { 363 | return "", fmt.Errorf( 364 | "GET %s\nError parsing json: %s", 365 | authURL, 366 | err, 367 | ) 368 | } 369 | // Check token valid 370 | if respObject.Token == "" { 371 | return "", fmt.Errorf( 372 | "GET %s\nAuth token invalid. Response: %+v", 373 | authURL, 374 | respObject, 375 | ) 376 | } 377 | return respObject.Token, nil 378 | } 379 | --------------------------------------------------------------------------------