├── .circleci └── config.yml ├── LICENSE.txt ├── README.md ├── arguments ├── parse.go └── parse_test.go └── main.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | docker: 6 | - image: circleci/golang:1.10.3 7 | working_directory: /go/src/github.com/joshdk/docker-retag 8 | steps: 9 | - checkout 10 | - run: 11 | name: Describe commit 12 | command: git describe --tags 13 | - run: 14 | name: Run tests 15 | command: | 16 | gofmt -l -s -w . 17 | go vet -all -shadow=true . 18 | go test -race -v ./... 19 | - run: 20 | name: Build binary 21 | command: | 22 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a \ 23 | -ldflags="-s -w" \ 24 | -o artifacts/docker-retag_linux_amd64 . 25 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -a \ 26 | -ldflags="-s -w" \ 27 | -o artifacts/docker-retag_darwin_amd64 . 28 | - run: 29 | name: Install UPX 30 | working_directory: /tmp 31 | command: | 32 | wget https://github.com/upx/upx/releases/download/v3.94/upx-3.94-amd64_linux.tar.xz 33 | tar --strip=1 -xf upx-3.94-amd64_linux.tar.xz 34 | sudo install upx /usr/bin 35 | - run: 36 | name: Compress binary 37 | command: upx --best --ultra-brute artifacts/docker-retag* 38 | - run: 39 | name: Copy binary 40 | command: cp artifacts/docker-retag_linux_amd64 artifacts/docker-retag 41 | - run: 42 | name: Checksum binary 43 | working_directory: artifacts 44 | command: sha256sum --binary --tag docker-retag* | tee checksums.txt 45 | - store_artifacts: 46 | path: artifacts 47 | destination: /artifacts 48 | - persist_to_workspace: 49 | root: . 50 | paths: 51 | - artifacts 52 | 53 | release: 54 | docker: 55 | - image: cibuilds/github:0.12.0 56 | working_directory: /go/src/github.com/joshdk/docker-retag 57 | steps: 58 | - attach_workspace: 59 | at: . 60 | - run: 61 | name: Upload artifacts 62 | command: ghr -u joshdk -r docker-retag -replace ${CIRCLE_TAG} artifacts 63 | 64 | workflows: 65 | version: 2 66 | build: 67 | jobs: 68 | - build 69 | 70 | release: 71 | jobs: 72 | - build: 73 | filters: 74 | branches: 75 | ignore: /.*/ 76 | tags: 77 | only: /.*/ 78 | - release: 79 | requires: 80 | - build 81 | filters: 82 | branches: 83 | ignore: /.*/ 84 | tags: 85 | only: /.*/ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Josh Komoroske 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 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI][circleci-badge]][circleci-link] 2 | [![Go Report Card][go-report-card-badge]][go-report-card-link] 3 | [![License][license-badge]][license-link] 4 | [![Github downloads][github-downloads-badge]][github-release-link] 5 | [![GitHub release][github-release-badge]][github-release-link] 6 | 7 | # Docker Retag 8 | 9 | 🐳 Retag an existing Docker image without the overhead of pulling and pushing 10 | 11 | ## Motivation 12 | 13 | There are certain situation where it is desirable to give an existing Docker image an additional tag. This is usually acomplished by a `docker pull`, followed by a `docker tag` and a `docker push`. 14 | 15 | That approach has the downside of downloading the contents of every layer from Docker Hub, which has bandwidth and performance implications, especially in a CI environment. 16 | 17 | This tool uses the [Docker Hub API](https://docs.docker.com/registry/spec/api/) to pull and push only a tiny [manifest](https://docs.docker.com/registry/spec/manifest-v2-2/) of the layers, bypassing the download overhead. Using this approach, an image of any size can be retagged in approximately 2 seconds. 18 | 19 | ## Installing 20 | 21 | ### From source 22 | 23 | You can use `go get` to install this tool by running: 24 | 25 | ```bash 26 | $ go get -u github.com/joshdk/docker-retag 27 | ``` 28 | 29 | ### Precompiled binary 30 | 31 | Alternatively, you can download a static Linux [release][github-release-link] binary by running: 32 | 33 | ```bash 34 | $ wget -q https://github.com/joshdk/docker-retag/releases/download/0.0.2/docker-retag 35 | $ sudo install docker-retag /usr/bin 36 | ``` 37 | 38 | ## Usage 39 | 40 | ### Setup 41 | 42 | Since `docker-retag` communicates with the [Docker Hub](https://hub.docker.com/) API, you must first export your account credentials into the working environment. These are the same credentials that you would use during `docker login`. 43 | 44 | ```bash 45 | $ export DOCKER_USER='joshdk' 46 | $ export DOCKER_PASS='hunter2' 47 | ``` 48 | 49 | The credentials must have both pull and push access for the Docker repository you are retagging. 50 | 51 | ### Examples 52 | 53 | This tool can be used in a few simple ways. The simplest of which is using a 54 | source image reference (similar to anything you could pass to `docker tag`) and 55 | a target tag. 56 | 57 | ##### Referencing a source image by tag name. 58 | 59 | ```bash 60 | $ docker-retag joshdk/hello-world:1.0.0 1.0.1 61 | Retagged joshdk/hello-world:1.0.0 as joshdk/hello-world:1.0.1 62 | ``` 63 | 64 | ##### Referencing a source image by `sha256` digest. 65 | 66 | ```bash 67 | $ docker-retag joshdk/hello-world@sha256:933f...3e90 1.0.1 68 | Retagged joshdk/hello-world@sha256:933f...3e90 as joshdk/hello-world:1.0.1 69 | ``` 70 | 71 | ##### Referencing an image only by name will default to using `latest`. 72 | 73 | ```bash 74 | $ docker-retag joshdk/hello-world 1.0.1 75 | Retagged joshdk/hello-world:latest as joshdk/hello-world:1.0.1 76 | ``` 77 | 78 | Additionally, you can pass the image name, source reference, and target tag as seperate arguments. 79 | 80 | ```bash 81 | $ docker-retag joshdk/hello-world 1.0.0 1.0.1 82 | Retagged joshdk/hello-world:1.0.0 as joshdk/hello-world:1.0.1 83 | ``` 84 | 85 | ```bash 86 | $ docker-retag joshdk/hello-world @sha256:933f...3e90 1.0.1 87 | Retagged joshdk/hello-world@sha256:933f...3e90 as joshdk/hello-world:1.0.1 88 | ``` 89 | 90 | In all cases, the image and source reference **must** already exist in Docker Hub. 91 | 92 | ## License 93 | 94 | This library is distributed under the [MIT License][license-link], see [LICENSE.txt][license-file] for more information. 95 | 96 | [circleci-badge]: https://circleci.com/gh/joshdk/docker-retag.svg?&style=shield 97 | [circleci-link]: https://circleci.com/gh/joshdk/docker-retag/tree/master 98 | [github-downloads-badge]: https://img.shields.io/github/downloads/joshdk/docker-retag/total.svg 99 | [github-release-badge]: https://img.shields.io/github/release/joshdk/docker-retag.svg 100 | [github-release-link]: https://github.com/joshdk/docker-retag/releases/latest 101 | [go-report-card-badge]: https://goreportcard.com/badge/github.com/joshdk/docker-retag 102 | [go-report-card-link]: https://goreportcard.com/report/github.com/joshdk/docker-retag 103 | [license-badge]: https://img.shields.io/github/license/joshdk/docker-retag.svg 104 | [license-file]: https://github.com/joshdk/docker-retag/blob/master/LICENSE.txt 105 | [license-link]: https://opensource.org/licenses/MIT 106 | -------------------------------------------------------------------------------- /arguments/parse.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Josh Komoroske. All rights reserved. 2 | // Use of this source code is governed by an MIT-style 3 | // license that can be found in the LICENSE.txt file. 4 | 5 | package arguments 6 | 7 | import ( 8 | "errors" 9 | "regexp" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | errorInvalidArguments = errors.New("invalid arguments") 15 | errorInvalidImageName = errors.New("invalid image name") 16 | errorInvalidSourceReference = errors.New("invalid source reference") 17 | errorInvalidTargetReference = errors.New("invalid target reference") 18 | ) 19 | 20 | func Parse(args []string) (string, string, string, error) { 21 | var ( 22 | rawName string 23 | rawSourceReference string 24 | rawTargetReference string 25 | canonicalName string 26 | canonicalSourceReference string 27 | canonicalTargetReference string 28 | ) 29 | 30 | switch len(args) { 31 | case 3: 32 | rawName, rawSourceReference, rawTargetReference = args[0], args[1], args[2] 33 | case 2: 34 | rawName, rawSourceReference = splitImageRef(args[0]) 35 | rawTargetReference = args[1] 36 | default: 37 | return "", "", "", errorInvalidArguments 38 | } 39 | 40 | if name, ok := maybeName(rawName); ok { 41 | canonicalName = name 42 | } else { 43 | return "", "", "", errorInvalidImageName 44 | } 45 | 46 | if digest, ok := maybeSHA256Digest(rawSourceReference); ok { 47 | canonicalSourceReference = digest 48 | } else if tag, ok := maybeTag(rawSourceReference); ok { 49 | canonicalSourceReference = tag 50 | } else { 51 | return "", "", "", errorInvalidSourceReference 52 | } 53 | 54 | if tag, ok := maybeTag(rawTargetReference); ok { 55 | canonicalTargetReference = tag 56 | } else { 57 | return "", "", "", errorInvalidTargetReference 58 | } 59 | 60 | return canonicalName, canonicalSourceReference, canonicalTargetReference, nil 61 | } 62 | 63 | // splitImageRef takes the given arg, and splits it into the name and 64 | // reference components. If there is no reference, the tag :latest is used. 65 | // 66 | // The name and reference components are not suitable for direct consumption, 67 | // and must be passed to maybeName or maybeTag/maybeSHA256Digest respectively. 68 | func splitImageRef(arg string) (name string, reference string) { 69 | // Do we have a reference with a digest? 70 | // example/image@sha256:acd...def 71 | if chunks := strings.SplitN(arg, "@", 2); len(chunks) == 2 { 72 | return chunks[0], "@" + chunks[1] 73 | } 74 | 75 | // Do we have a reference with a tag? 76 | // example/image:1.2.3 77 | if chunks := strings.SplitN(arg, ":", 2); len(chunks) == 2 { 78 | return chunks[0], ":" + chunks[1] 79 | } 80 | 81 | return arg, ":latest" 82 | } 83 | 84 | // maybeName takes the given arg, and determines if it looks like an image name. 85 | // If successful, a canonical name is returned, and a true value indicating 86 | // success. 87 | // 88 | // The canonical name is acceptable to be used in Docker Hup API urls. 89 | // 90 | // This function attempts to preform some helpful input handling. Such as 91 | // stripping any docker.io image name prefix, and handling library images that 92 | // do not require an organization prefix. 93 | func maybeName(arg string) (image string, isImage bool) { 94 | var ( 95 | chunks = strings.Split(strings.TrimPrefix(arg, "docker.io/"), "/") 96 | org string 97 | repo string 98 | ) 99 | 100 | switch len(chunks) { 101 | case 2: 102 | org, repo = chunks[0], chunks[1] 103 | case 1: 104 | org, repo = "library", chunks[0] 105 | } 106 | 107 | if len(org) == 0 || len(repo) == 0 { 108 | return "", false 109 | } 110 | 111 | return org + "/" + repo, true 112 | } 113 | 114 | // maybeTag takes the given arg, and determines if it looks like a tag based 115 | // image reference. If successful, a canonical tag is returned, and a true 116 | // value indicating success. 117 | // 118 | // The canonical tag is acceptable to be used in Docker Hup API urls. 119 | func maybeTag(arg string) (tag string, isTag bool) { 120 | //https://github.com/docker/distribution/blob/master/reference/regexp.go#L36 121 | tagRegex := regexp.MustCompile(`^:?([\w][\w.-]{0,127})$`) 122 | chunks := tagRegex.FindStringSubmatch(arg) 123 | 124 | if len(chunks) == 2 { 125 | return chunks[1], true 126 | } 127 | return "", false 128 | } 129 | 130 | // maybeSHA256Digest takes the given arg, and determines if it looks like a 131 | // digest based image reference. If successful, a canonical digest is returned, 132 | // and a true value indicating success. 133 | // 134 | // The canonical digest is acceptable to be used in Docker Hup API urls. 135 | // 136 | // This function is specifically more restrictive with allowed digests compared 137 | // to what is allowed by the specification. The sha256 digest type is the only 138 | // implementation at the moment, so it is safer to limit against that. 139 | func maybeSHA256Digest(arg string) (digest string, isDigest bool) { 140 | //https://github.com/docker/distribution/blob/master/reference/regexp.go#L43 141 | digestRegex := regexp.MustCompile(`^(@|sha256:|@sha256:)([0-9a-f]{64})$`) 142 | chunks := digestRegex.FindStringSubmatch(arg) 143 | 144 | if len(chunks) == 3 { 145 | return "sha256:" + chunks[2], true 146 | } 147 | return "", false 148 | } 149 | -------------------------------------------------------------------------------- /arguments/parse_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Josh Komoroske. All rights reserved. 2 | // Use of this source code is governed by an MIT-style 3 | // license that can be found in the LICENSE.txt file. 4 | 5 | package arguments 6 | 7 | import ( 8 | "fmt" 9 | "testing" 10 | ) 11 | 12 | func TestParse(t *testing.T) { 13 | 14 | tests := []struct { 15 | title string 16 | args []string 17 | expectedName string 18 | expectedSourceReference string 19 | expectedTargetReference string 20 | expectedError error 21 | }{ 22 | { 23 | title: "Nil args", 24 | expectedError: errorInvalidArguments, 25 | }, 26 | { 27 | title: "Empty args", 28 | args: []string{}, 29 | expectedError: errorInvalidArguments, 30 | }, 31 | { 32 | title: "Too many components", 33 | args: []string{"docker.io/library/org/example:1.2.3", ":4.5.6"}, 34 | expectedError: errorInvalidImageName, 35 | }, 36 | { 37 | title: "Target digest", 38 | args: []string{"org/example", ":1.2.3", "@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd"}, 39 | expectedError: errorInvalidTargetReference, 40 | }, 41 | { 42 | title: "Source digest with colon", 43 | args: []string{"org/example", ":sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", "4.5.6"}, 44 | expectedError: errorInvalidSourceReference, 45 | }, 46 | { 47 | "Tags", 48 | []string{"org/example", "1.2.3", "4.5.6"}, 49 | "org/example", "1.2.3", "4.5.6", 50 | nil, 51 | }, 52 | { 53 | "Tags with colons", 54 | []string{"org/example", ":1.2.3", ":4.5.6"}, 55 | "org/example", "1.2.3", "4.5.6", 56 | nil, 57 | }, 58 | { 59 | "Digest with @", 60 | []string{"org/example", "@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", ":4.5.6"}, 61 | "org/example", "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", "4.5.6", 62 | nil, 63 | }, 64 | { 65 | "Digest with sha256", 66 | []string{"org/example", "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", ":4.5.6"}, 67 | "org/example", "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", "4.5.6", 68 | nil, 69 | }, 70 | { 71 | "Digest with @sha256", 72 | []string{"org/example", "@sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", ":4.5.6"}, 73 | "org/example", "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", "4.5.6", 74 | nil, 75 | }, 76 | { 77 | "No tag", 78 | []string{"org/example", ":4.5.6"}, 79 | "org/example", "latest", "4.5.6", 80 | nil, 81 | }, 82 | { 83 | "Joined tag", 84 | []string{"org/example:1.2.3", ":4.5.6"}, 85 | "org/example", "1.2.3", "4.5.6", 86 | nil, 87 | }, 88 | { 89 | "Joined digest with @", 90 | []string{"org/example@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", ":4.5.6"}, 91 | "org/example", "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", "4.5.6", 92 | nil, 93 | }, 94 | { 95 | "Joined digest with @sha256", 96 | []string{"org/example@sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", ":4.5.6"}, 97 | "org/example", "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", "4.5.6", 98 | nil, 99 | }, 100 | } 101 | 102 | for index, test := range tests { 103 | name := fmt.Sprintf("Case #%d %s", index+1, test.title) 104 | t.Run(name, func(t *testing.T) { 105 | 106 | name, sourceReference, targetReference, err := Parse(test.args) 107 | if err != test.expectedError { 108 | panic(fmt.Sprintf("Expected %v, actual %v", test.expectedError, err)) 109 | } 110 | if name != test.expectedName { 111 | panic(fmt.Sprintf("Expected %s, actual %s", test.expectedName, name)) 112 | } 113 | if sourceReference != test.expectedSourceReference { 114 | panic(fmt.Sprintf("Expected %s, actual %s", test.expectedSourceReference, sourceReference)) 115 | } 116 | if targetReference != test.expectedTargetReference { 117 | panic(fmt.Sprintf("Expected %s, actual %s", test.expectedTargetReference, targetReference)) 118 | } 119 | }) 120 | } 121 | } 122 | 123 | func TestSplitImageRef(t *testing.T) { 124 | 125 | tests := []struct { 126 | title string 127 | arg string 128 | expectedName string 129 | expectedReference string 130 | }{ 131 | { 132 | "Empty string", 133 | "", 134 | "", ":latest", 135 | }, 136 | { 137 | "Only colon", 138 | ":", 139 | "", ":", 140 | }, 141 | { 142 | "Only @", 143 | "@", 144 | "", "@", 145 | }, 146 | { 147 | "Bare image", 148 | "org/example", 149 | "org/example", ":latest", 150 | }, 151 | { 152 | "Image tagged latest", 153 | "org/example:latest", 154 | "org/example", ":latest", 155 | }, 156 | { 157 | "Image tagged with semver", 158 | "org/example:1.2.3", 159 | "org/example", ":1.2.3", 160 | }, 161 | { 162 | "Image tagged with double colon", 163 | "org/example::1.2.3", 164 | "org/example", "::1.2.3", 165 | }, 166 | { 167 | "Image with digest", 168 | "org/example@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 169 | "org/example", "@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 170 | }, 171 | { 172 | "Image with sha256 digest", 173 | "org/example@sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 174 | "org/example", "@sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 175 | }, 176 | { 177 | "Image tagged with double @", 178 | "org/example@@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 179 | "org/example", "@@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 180 | }, 181 | } 182 | 183 | for index, test := range tests { 184 | name := fmt.Sprintf("Case #%d %s", index+1, test.title) 185 | t.Run(name, func(t *testing.T) { 186 | 187 | name, reference := splitImageRef(test.arg) 188 | if name != test.expectedName { 189 | panic(fmt.Sprintf("Expected %s, actual %s", test.expectedName, name)) 190 | } 191 | if reference != test.expectedReference { 192 | panic(fmt.Sprintf("Expected %s, actual %s", test.expectedReference, reference)) 193 | } 194 | }) 195 | } 196 | } 197 | 198 | func TestMaybeName(t *testing.T) { 199 | 200 | tests := []struct { 201 | title string 202 | arg string 203 | expectedName string 204 | expectedIsName bool 205 | }{ 206 | { 207 | title: "Empty string", 208 | }, 209 | { 210 | title: "Too many components", 211 | arg: "docker.io/library/org/example", 212 | }, 213 | { 214 | title: "Empty org", 215 | arg: "/example", 216 | }, 217 | { 218 | title: "Empty repo", 219 | arg: "org/", 220 | }, 221 | { 222 | title: "Empty org and repo", 223 | arg: "/", 224 | }, 225 | { 226 | title: "Too many slashes", 227 | arg: "org//example", 228 | }, 229 | { 230 | title: "Leading slash", 231 | arg: "/org/example", 232 | }, 233 | { 234 | title: "Trailing slash", 235 | arg: "org/example/", 236 | }, 237 | { 238 | "Bare library image", 239 | "example", "library/example", 240 | true, 241 | }, 242 | { 243 | "Library image with library prefix", 244 | "library/example", "library/example", 245 | true, 246 | }, 247 | { 248 | "Library image with docker.io prefix", 249 | "docker.io/example", "library/example", 250 | true, 251 | }, 252 | { 253 | "Library image with both docker.io and library prefix", 254 | "docker.io/library/example", "library/example", 255 | true, 256 | }, 257 | { 258 | "Org image", 259 | "org/example", "org/example", 260 | true, 261 | }, 262 | { 263 | "Org image with docker.io prefix", 264 | "docker.io/org/example", "org/example", 265 | true, 266 | }, 267 | } 268 | 269 | for index, test := range tests { 270 | name := fmt.Sprintf("Case #%d %s", index+1, test.title) 271 | t.Run(name, func(t *testing.T) { 272 | 273 | name, isName := maybeName(test.arg) 274 | if isName != test.expectedIsName { 275 | panic(fmt.Sprintf("Expected %v, actual %v", test.expectedIsName, isName)) 276 | } 277 | if name != test.expectedName { 278 | panic(fmt.Sprintf("Expected %s, actual %s", test.expectedName, name)) 279 | } 280 | }) 281 | } 282 | } 283 | 284 | func TestMaybeTag(t *testing.T) { 285 | 286 | tests := []struct { 287 | title string 288 | arg string 289 | expectedTag string 290 | expectedIsTag bool 291 | }{ 292 | { 293 | title: "Empty string", 294 | }, 295 | { 296 | title: "Only colon", 297 | arg: ":", 298 | }, 299 | { 300 | title: "Double colon", 301 | arg: "::1.2.3", 302 | }, 303 | { 304 | "Word tag", 305 | "latest", "latest", 306 | true, 307 | }, 308 | { 309 | "Word tag with colon", 310 | ":latest", "latest", 311 | true, 312 | }, 313 | { 314 | "Semver tag", 315 | "1.2.3", "1.2.3", 316 | true, 317 | }, 318 | { 319 | "Semver tag with colon", 320 | ":1.2.3", "1.2.3", 321 | true, 322 | }, 323 | { 324 | "Complex example tag 1", 325 | ":7u181-jre-alpine3.7", "7u181-jre-alpine3.7", 326 | true, 327 | }, 328 | { 329 | "Complex example tag 2", 330 | ":1.10rc2-stretch", "1.10rc2-stretch", 331 | true, 332 | }, 333 | { 334 | "Complex example tag 3", 335 | ":4.0.0-rc6-xenial", "4.0.0-rc6-xenial", 336 | true, 337 | }, 338 | } 339 | 340 | for index, test := range tests { 341 | name := fmt.Sprintf("Case #%d %s", index+1, test.title) 342 | t.Run(name, func(t *testing.T) { 343 | 344 | tag, isTag := maybeTag(test.arg) 345 | if isTag != test.expectedIsTag { 346 | panic(fmt.Sprintf("Expected %v, actual %v", test.expectedIsTag, isTag)) 347 | } 348 | if tag != test.expectedTag { 349 | panic(fmt.Sprintf("Expected %s, actual %s", test.expectedTag, tag)) 350 | } 351 | }) 352 | } 353 | } 354 | 355 | func TestMaybeDigest(t *testing.T) { 356 | 357 | tests := []struct { 358 | title string 359 | arg string 360 | expectedDigest string 361 | expectedIsDigest bool 362 | }{ 363 | { 364 | title: "Empty string", 365 | }, 366 | { 367 | title: "Only @", 368 | arg: "@", 369 | }, 370 | { 371 | title: "Double @", 372 | arg: "@@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 373 | }, 374 | { 375 | title: "Digest too short", 376 | arg: "@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333ddddddd", 377 | }, 378 | { 379 | title: "Digest too long", 380 | arg: "@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd4", 381 | }, 382 | { 383 | title: "Digest uppercase hex", 384 | arg: "@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddD", 385 | }, 386 | { 387 | title: "Digest illegal letter", 388 | arg: "@O0000000aaaaaaaal1111111bbbbbbbb22222222cccccccc33333333dddddddd", 389 | }, 390 | { 391 | title: "Wrong digest type", 392 | arg: "@sha512:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 393 | }, 394 | { 395 | "Digest with @", 396 | "@00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 397 | "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 398 | true, 399 | }, 400 | { 401 | "Digest with sha256", 402 | "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 403 | "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 404 | true, 405 | }, 406 | { 407 | "Digest with @sha256", 408 | "@sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 409 | "sha256:00000000aaaaaaaa11111111bbbbbbbb22222222cccccccc33333333dddddddd", 410 | true, 411 | }, 412 | } 413 | 414 | for index, test := range tests { 415 | name := fmt.Sprintf("Case #%d %s", index+1, test.title) 416 | t.Run(name, func(t *testing.T) { 417 | 418 | digest, isDigest := maybeSHA256Digest(test.arg) 419 | if isDigest != test.expectedIsDigest { 420 | panic(fmt.Sprintf("Expected %v, actual %v", test.expectedIsDigest, isDigest)) 421 | } 422 | if digest != test.expectedDigest { 423 | panic(fmt.Sprintf("Expected %s, actual %s", test.expectedDigest, digest)) 424 | } 425 | }) 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Josh Komoroske. All rights reserved. 2 | // Use of this source code is governed by an MIT-style 3 | // license that can be found in the LICENSE.txt file. 4 | 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "io/ioutil" 13 | "net/http" 14 | "os" 15 | 16 | "github.com/joshdk/docker-retag/arguments" 17 | "strings" 18 | ) 19 | 20 | const ( 21 | dockerUsernameEnv = "DOCKER_USER" 22 | dockerPasswordEnv = "DOCKER_PASS" 23 | ) 24 | 25 | func main() { 26 | if err := mainCmd(os.Args); err != nil { 27 | fmt.Fprintf(os.Stderr, "docker-retag: %s\n", err.Error()) 28 | os.Exit(1) 29 | } 30 | } 31 | 32 | func mainCmd(args []string) error { 33 | var ( 34 | repository, oldTag, newTag, err = arguments.Parse(args[1:]) 35 | ) 36 | 37 | if err != nil { 38 | return err 39 | } 40 | 41 | username, found := os.LookupEnv(dockerUsernameEnv) 42 | if !found { 43 | return errors.New(dockerUsernameEnv + " not found in environment") 44 | } 45 | 46 | password, found := os.LookupEnv(dockerPasswordEnv) 47 | if !found { 48 | return errors.New(dockerPasswordEnv + " not found in environment") 49 | } 50 | 51 | token, err := login(repository, username, password) 52 | if err != nil { 53 | return errors.New("failed to authenticate: " + err.Error()) 54 | } 55 | 56 | manifest, err := pullManifest(token, repository, oldTag) 57 | if err != nil { 58 | return errors.New("failed to pull manifest: " + err.Error()) 59 | } 60 | 61 | if err := pushManifest(token, repository, newTag, manifest); err != nil { 62 | return errors.New("failed to push manifest: " + err.Error()) 63 | } 64 | 65 | separator := ":" 66 | if strings.HasPrefix(oldTag, "sha256:") { 67 | separator = "@" 68 | } 69 | 70 | fmt.Printf("Retagged %s%s%s as %s:%s\n", repository, separator, oldTag, repository, newTag) 71 | 72 | return nil 73 | } 74 | 75 | func login(repo string, username string, password string) (string, error) { 76 | var ( 77 | client = http.DefaultClient 78 | url = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:" + repo + ":pull,push" 79 | ) 80 | 81 | req, err := http.NewRequest("GET", url, nil) 82 | if err != nil { 83 | return "", err 84 | } 85 | 86 | req.SetBasicAuth(username, password) 87 | 88 | resp, err := client.Do(req) 89 | if err != nil { 90 | return "", err 91 | } 92 | 93 | if resp.StatusCode != http.StatusOK { 94 | return "", errors.New(resp.Status) 95 | } 96 | 97 | bodyText, err := ioutil.ReadAll(resp.Body) 98 | if err != nil { 99 | return "", err 100 | } 101 | 102 | var data struct { 103 | Details string `json:"details"` 104 | Token string `json:"token"` 105 | } 106 | 107 | if err := json.Unmarshal(bodyText, &data); err != nil { 108 | return "", err 109 | } 110 | 111 | if data.Token == "" { 112 | return "", errors.New("empty token") 113 | } 114 | 115 | return data.Token, nil 116 | } 117 | 118 | func pullManifest(token string, repository string, tag string) ([]byte, error) { 119 | var ( 120 | client = http.DefaultClient 121 | url = "https://index.docker.io/v2/" + repository + "/manifests/" + tag 122 | ) 123 | 124 | req, err := http.NewRequest("GET", url, nil) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | req.Header.Set("Authorization", "Bearer "+token) 130 | req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json") 131 | 132 | resp, err := client.Do(req) 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | if resp.StatusCode != http.StatusOK { 138 | return nil, errors.New(resp.Status) 139 | } 140 | 141 | bodyText, err := ioutil.ReadAll(resp.Body) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | return bodyText, nil 147 | } 148 | 149 | func pushManifest(token string, repository string, tag string, manifest []byte) error { 150 | var ( 151 | client = http.DefaultClient 152 | url = "https://index.docker.io/v2/" + repository + "/manifests/" + tag 153 | ) 154 | 155 | req, err := http.NewRequest("PUT", url, bytes.NewBuffer(manifest)) 156 | if err != nil { 157 | return err 158 | } 159 | 160 | req.Header.Set("Authorization", "Bearer "+token) 161 | req.Header.Set("Content-type", "application/vnd.docker.distribution.manifest.v2+json") 162 | 163 | resp, err := client.Do(req) 164 | if err != nil { 165 | return err 166 | } 167 | 168 | if resp.StatusCode != http.StatusCreated { 169 | return errors.New(resp.Status) 170 | } 171 | 172 | return nil 173 | } 174 | --------------------------------------------------------------------------------