├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── archive ├── archive.go ├── archive_test.go ├── test-fixtures │ ├── archive-dir-mode │ │ └── file.txt │ ├── archive-file-compressed │ │ └── file.tar.gz │ ├── archive-file │ │ └── foo.txt │ ├── archive-flat │ │ ├── baz.txt │ │ └── foo.txt │ ├── archive-git │ │ ├── DOTgit │ │ │ ├── COMMIT_EDITMSG │ │ │ ├── HEAD │ │ │ ├── config │ │ │ ├── description │ │ │ ├── hooks │ │ │ │ ├── applypatch-msg.sample │ │ │ │ ├── commit-msg.sample │ │ │ │ ├── post-update.sample │ │ │ │ ├── pre-applypatch.sample │ │ │ │ ├── pre-commit.sample │ │ │ │ ├── pre-push.sample │ │ │ │ ├── pre-rebase.sample │ │ │ │ ├── prepare-commit-msg.sample │ │ │ │ └── update.sample │ │ │ ├── index │ │ │ ├── info │ │ │ │ └── exclude │ │ │ ├── logs │ │ │ │ ├── HEAD │ │ │ │ └── refs │ │ │ │ │ └── heads │ │ │ │ │ └── master │ │ │ ├── objects │ │ │ │ ├── 25 │ │ │ │ │ └── 7cc5642cb1a054f08cc83f2d943e56fd3ebe99 │ │ │ │ ├── 57 │ │ │ │ │ └── 16ca5987cbf97d6bb54920bea6adde242d87e6 │ │ │ │ ├── 75 │ │ │ │ │ └── 25d17cbbb56f3253a20903ffddc07c6c935c76 │ │ │ │ ├── 7e │ │ │ │ │ └── 49ea5550b356e32b63c044201f5f7da1e0925f │ │ │ │ └── 7f │ │ │ │ │ └── 7402c7d2a6e71ca3db3e236099771b160b8ad1 │ │ │ └── refs │ │ │ │ └── heads │ │ │ │ └── master │ │ ├── bar.txt │ │ ├── foo.txt │ │ ├── subdir │ │ │ └── hello.txt │ │ └── untracked.txt │ ├── archive-hg │ │ ├── .hg │ │ │ ├── 00changelog.i │ │ │ ├── cache │ │ │ │ └── branch2-served │ │ │ ├── dirstate │ │ │ ├── last-message.txt │ │ │ ├── requires │ │ │ ├── store │ │ │ │ ├── 00changelog.i │ │ │ │ ├── 00manifest.i │ │ │ │ ├── data │ │ │ │ │ ├── bar.txt.i │ │ │ │ │ ├── foo.txt.i │ │ │ │ │ └── subdir │ │ │ │ │ │ └── hello.txt.i │ │ │ │ ├── fncache │ │ │ │ ├── phaseroots │ │ │ │ ├── undo │ │ │ │ └── undo.phaseroots │ │ │ ├── undo.bookmarks │ │ │ ├── undo.branch │ │ │ ├── undo.desc │ │ │ └── undo.dirstate │ │ ├── bar.txt │ │ ├── foo.txt │ │ └── subdir │ │ │ └── hello.txt │ ├── archive-subdir-splat │ │ ├── bar.txt │ │ └── build │ │ │ ├── darwin-amd64 │ │ │ └── build.txt │ │ │ └── linux-amd64 │ │ │ └── build.txt │ ├── archive-subdir │ │ ├── bar.txt │ │ ├── foo.txt │ │ └── subdir │ │ │ └── hello.txt │ ├── archive-symlink-file │ │ ├── link │ │ │ ├── deeper │ │ │ │ ├── adeeperlink │ │ │ │ ├── linklink │ │ │ │ └── linklinklink │ │ │ └── link │ │ └── real │ │ │ └── foo.txt │ └── archive-symlink │ │ ├── link │ │ └── link │ │ └── real │ │ └── foo.txt ├── vcs.go └── vcs_test.go └── v1 ├── application.go ├── application_test.go ├── artifact.go ├── artifact_test.go ├── atlas_test.go ├── authentication.go ├── authentication_test.go ├── build_config.go ├── build_config_test.go ├── client.go ├── client_test.go ├── terraform.go ├── terraform_test.go ├── util.go └── util_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | ### Go ### 2 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 3 | *.o 4 | *.a 5 | *.so 6 | 7 | # Folders 8 | _obj 9 | _test 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | *.exe 24 | *.test 25 | *.prof 26 | 27 | /bin/ 28 | /build/ 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: go 4 | 5 | go: 6 | - 1.7 7 | - 1.8 8 | 9 | branches: 10 | only: 11 | - master 12 | 13 | script: make deps test 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 HashiCorp, Inc. 2 | 3 | Mozilla Public License, version 2.0 4 | 5 | 1. Definitions 6 | 7 | 1.1. “Contributor” 8 | 9 | means each individual or legal entity that creates, contributes to the 10 | creation of, or owns Covered Software. 11 | 12 | 1.2. “Contributor Version” 13 | 14 | means the combination of the Contributions of others (if any) used by a 15 | Contributor and that particular Contributor’s Contribution. 16 | 17 | 1.3. “Contribution” 18 | 19 | means Covered Software of a particular Contributor. 20 | 21 | 1.4. “Covered Software” 22 | 23 | means Source Code Form to which the initial Contributor has attached the 24 | notice in Exhibit A, the Executable Form of such Source Code Form, and 25 | Modifications of such Source Code Form, in each case including portions 26 | thereof. 27 | 28 | 1.5. “Incompatible With Secondary Licenses” 29 | means 30 | 31 | a. that the initial Contributor has attached the notice described in 32 | Exhibit B to the Covered Software; or 33 | 34 | b. that the Covered Software was made available under the terms of version 35 | 1.1 or earlier of the License, but not also under the terms of a 36 | Secondary License. 37 | 38 | 1.6. “Executable Form” 39 | 40 | means any form of the work other than Source Code Form. 41 | 42 | 1.7. “Larger Work” 43 | 44 | means a work that combines Covered Software with other material, in a separate 45 | file or files, that is not Covered Software. 46 | 47 | 1.8. “License” 48 | 49 | means this document. 50 | 51 | 1.9. “Licensable” 52 | 53 | means having the right to grant, to the maximum extent possible, whether at the 54 | time of the initial grant or subsequently, any and all of the rights conveyed by 55 | this License. 56 | 57 | 1.10. “Modifications” 58 | 59 | means any of the following: 60 | 61 | a. any file in Source Code Form that results from an addition to, deletion 62 | from, or modification of the contents of Covered Software; or 63 | 64 | b. any new file in Source Code Form that contains any Covered Software. 65 | 66 | 1.11. “Patent Claims” of a Contributor 67 | 68 | means any patent claim(s), including without limitation, method, process, 69 | and apparatus claims, in any patent Licensable by such Contributor that 70 | would be infringed, but for the grant of the License, by the making, 71 | using, selling, offering for sale, having made, import, or transfer of 72 | either its Contributions or its Contributor Version. 73 | 74 | 1.12. “Secondary License” 75 | 76 | means either the GNU General Public License, Version 2.0, the GNU Lesser 77 | General Public License, Version 2.1, the GNU Affero General Public 78 | License, Version 3.0, or any later versions of those licenses. 79 | 80 | 1.13. “Source Code Form” 81 | 82 | means the form of the work preferred for making modifications. 83 | 84 | 1.14. “You” (or “Your”) 85 | 86 | means an individual or a legal entity exercising rights under this 87 | License. For legal entities, “You” includes any entity that controls, is 88 | controlled by, or is under common control with You. For purposes of this 89 | definition, “control” means (a) the power, direct or indirect, to cause 90 | the direction or management of such entity, whether by contract or 91 | otherwise, or (b) ownership of more than fifty percent (50%) of the 92 | outstanding shares or beneficial ownership of such entity. 93 | 94 | 95 | 2. License Grants and Conditions 96 | 97 | 2.1. Grants 98 | 99 | Each Contributor hereby grants You a world-wide, royalty-free, 100 | non-exclusive license: 101 | 102 | a. under intellectual property rights (other than patent or trademark) 103 | Licensable by such Contributor to use, reproduce, make available, 104 | modify, display, perform, distribute, and otherwise exploit its 105 | Contributions, either on an unmodified basis, with Modifications, or as 106 | part of a Larger Work; and 107 | 108 | b. under Patent Claims of such Contributor to make, use, sell, offer for 109 | sale, have made, import, and otherwise transfer either its Contributions 110 | or its Contributor Version. 111 | 112 | 2.2. Effective Date 113 | 114 | The licenses granted in Section 2.1 with respect to any Contribution become 115 | effective for each Contribution on the date the Contributor first distributes 116 | such Contribution. 117 | 118 | 2.3. Limitations on Grant Scope 119 | 120 | The licenses granted in this Section 2 are the only rights granted under this 121 | License. No additional rights or licenses will be implied from the distribution 122 | or licensing of Covered Software under this License. Notwithstanding Section 123 | 2.1(b) above, no patent license is granted by a Contributor: 124 | 125 | a. for any code that a Contributor has removed from Covered Software; or 126 | 127 | b. for infringements caused by: (i) Your and any other third party’s 128 | modifications of Covered Software, or (ii) the combination of its 129 | Contributions with other software (except as part of its Contributor 130 | Version); or 131 | 132 | c. under Patent Claims infringed by Covered Software in the absence of its 133 | Contributions. 134 | 135 | This License does not grant any rights in the trademarks, service marks, or 136 | logos of any Contributor (except as may be necessary to comply with the 137 | notice requirements in Section 3.4). 138 | 139 | 2.4. Subsequent Licenses 140 | 141 | No Contributor makes additional grants as a result of Your choice to 142 | distribute the Covered Software under a subsequent version of this License 143 | (see Section 10.2) or under the terms of a Secondary License (if permitted 144 | under the terms of Section 3.3). 145 | 146 | 2.5. Representation 147 | 148 | Each Contributor represents that the Contributor believes its Contributions 149 | are its original creation(s) or it has sufficient rights to grant the 150 | rights to its Contributions conveyed by this License. 151 | 152 | 2.6. Fair Use 153 | 154 | This License is not intended to limit any rights You have under applicable 155 | copyright doctrines of fair use, fair dealing, or other equivalents. 156 | 157 | 2.7. Conditions 158 | 159 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 160 | Section 2.1. 161 | 162 | 163 | 3. Responsibilities 164 | 165 | 3.1. Distribution of Source Form 166 | 167 | All distribution of Covered Software in Source Code Form, including any 168 | Modifications that You create or to which You contribute, must be under the 169 | terms of this License. You must inform recipients that the Source Code Form 170 | of the Covered Software is governed by the terms of this License, and how 171 | they can obtain a copy of this License. You may not attempt to alter or 172 | restrict the recipients’ rights in the Source Code Form. 173 | 174 | 3.2. Distribution of Executable Form 175 | 176 | If You distribute Covered Software in Executable Form then: 177 | 178 | a. such Covered Software must also be made available in Source Code Form, 179 | as described in Section 3.1, and You must inform recipients of the 180 | Executable Form how they can obtain a copy of such Source Code Form by 181 | reasonable means in a timely manner, at a charge no more than the cost 182 | of distribution to the recipient; and 183 | 184 | b. You may distribute such Executable Form under the terms of this License, 185 | or sublicense it under different terms, provided that the license for 186 | the Executable Form does not attempt to limit or alter the recipients’ 187 | rights in the Source Code Form under this License. 188 | 189 | 3.3. Distribution of a Larger Work 190 | 191 | You may create and distribute a Larger Work under terms of Your choice, 192 | provided that You also comply with the requirements of this License for the 193 | Covered Software. If the Larger Work is a combination of Covered Software 194 | with a work governed by one or more Secondary Licenses, and the Covered 195 | Software is not Incompatible With Secondary Licenses, this License permits 196 | You to additionally distribute such Covered Software under the terms of 197 | such Secondary License(s), so that the recipient of the Larger Work may, at 198 | their option, further distribute the Covered Software under the terms of 199 | either this License or such Secondary License(s). 200 | 201 | 3.4. Notices 202 | 203 | You may not remove or alter the substance of any license notices (including 204 | copyright notices, patent notices, disclaimers of warranty, or limitations 205 | of liability) contained within the Source Code Form of the Covered 206 | Software, except that You may alter any license notices to the extent 207 | required to remedy known factual inaccuracies. 208 | 209 | 3.5. Application of Additional Terms 210 | 211 | You may choose to offer, and to charge a fee for, warranty, support, 212 | indemnity or liability obligations to one or more recipients of Covered 213 | Software. However, You may do so only on Your own behalf, and not on behalf 214 | of any Contributor. You must make it absolutely clear that any such 215 | warranty, support, indemnity, or liability obligation is offered by You 216 | alone, and You hereby agree to indemnify every Contributor for any 217 | liability incurred by such Contributor as a result of warranty, support, 218 | indemnity or liability terms You offer. You may include additional 219 | disclaimers of warranty and limitations of liability specific to any 220 | jurisdiction. 221 | 222 | 4. Inability to Comply Due to Statute or Regulation 223 | 224 | If it is impossible for You to comply with any of the terms of this License 225 | with respect to some or all of the Covered Software due to statute, judicial 226 | order, or regulation then You must: (a) comply with the terms of this License 227 | to the maximum extent possible; and (b) describe the limitations and the code 228 | they affect. Such description must be placed in a text file included with all 229 | distributions of the Covered Software under this License. Except to the 230 | extent prohibited by statute or regulation, such description must be 231 | sufficiently detailed for a recipient of ordinary skill to be able to 232 | understand it. 233 | 234 | 5. Termination 235 | 236 | 5.1. The rights granted under this License will terminate automatically if You 237 | fail to comply with any of its terms. However, if You become compliant, 238 | then the rights granted under this License from a particular Contributor 239 | are reinstated (a) provisionally, unless and until such Contributor 240 | explicitly and finally terminates Your grants, and (b) on an ongoing basis, 241 | if such Contributor fails to notify You of the non-compliance by some 242 | reasonable means prior to 60 days after You have come back into compliance. 243 | Moreover, Your grants from a particular Contributor are reinstated on an 244 | ongoing basis if such Contributor notifies You of the non-compliance by 245 | some reasonable means, this is the first time You have received notice of 246 | non-compliance with this License from such Contributor, and You become 247 | compliant prior to 30 days after Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, counter-claims, 251 | and cross-claims) alleging that a Contributor Version directly or 252 | indirectly infringes any patent, then the rights granted to You by any and 253 | all Contributors for the Covered Software under Section 2.1 of this License 254 | shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 257 | license agreements (excluding distributors and resellers) which have been 258 | validly granted by You or Your distributors under this License prior to 259 | termination shall survive termination. 260 | 261 | 6. Disclaimer of Warranty 262 | 263 | Covered Software is provided under this License on an “as is” basis, without 264 | warranty of any kind, either expressed, implied, or statutory, including, 265 | without limitation, warranties that the Covered Software is free of defects, 266 | merchantable, fit for a particular purpose or non-infringing. The entire 267 | risk as to the quality and performance of the Covered Software is with You. 268 | Should any Covered Software prove defective in any respect, You (not any 269 | Contributor) assume the cost of any necessary servicing, repair, or 270 | correction. This disclaimer of warranty constitutes an essential part of this 271 | License. No use of any Covered Software is authorized under this License 272 | except under this disclaimer. 273 | 274 | 7. Limitation of Liability 275 | 276 | Under no circumstances and under no legal theory, whether tort (including 277 | negligence), contract, or otherwise, shall any Contributor, or anyone who 278 | distributes Covered Software as permitted above, be liable to You for any 279 | direct, indirect, special, incidental, or consequential damages of any 280 | character including, without limitation, damages for lost profits, loss of 281 | goodwill, work stoppage, computer failure or malfunction, or any and all 282 | other commercial damages or losses, even if such party shall have been 283 | informed of the possibility of such damages. This limitation of liability 284 | shall not apply to liability for death or personal injury resulting from such 285 | party’s negligence to the extent applicable law prohibits such limitation. 286 | Some jurisdictions do not allow the exclusion or limitation of incidental or 287 | consequential damages, so this exclusion and limitation may not apply to You. 288 | 289 | 8. Litigation 290 | 291 | Any litigation relating to this License may be brought only in the courts of 292 | a jurisdiction where the defendant maintains its principal place of business 293 | and such litigation shall be governed by laws of that jurisdiction, without 294 | reference to its conflict-of-law provisions. Nothing in this Section shall 295 | prevent a party’s ability to bring cross-claims or counter-claims. 296 | 297 | 9. Miscellaneous 298 | 299 | This License represents the complete agreement concerning the subject matter 300 | hereof. If any provision of this License is held to be unenforceable, such 301 | provision shall be reformed only to the extent necessary to make it 302 | enforceable. Any law or regulation which provides that the language of a 303 | contract shall be construed against the drafter shall not be used to construe 304 | this License against a Contributor. 305 | 306 | 307 | 10. Versions of the License 308 | 309 | 10.1. New Versions 310 | 311 | Mozilla Foundation is the license steward. Except as provided in Section 312 | 10.3, no one other than the license steward has the right to modify or 313 | publish new versions of this License. Each version will be given a 314 | distinguishing version number. 315 | 316 | 10.2. Effect of New Versions 317 | 318 | You may distribute the Covered Software under the terms of the version of 319 | the License under which You originally received the Covered Software, or 320 | under the terms of any subsequent version published by the license 321 | steward. 322 | 323 | 10.3. Modified Versions 324 | 325 | If you create software not governed by this License, and you want to 326 | create a new license for such software, you may create and use a modified 327 | version of this License if you rename the license and remove any 328 | references to the name of the license steward (except to note that such 329 | modified license differs from this License). 330 | 331 | 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses 332 | If You choose to distribute Source Code Form that is Incompatible With 333 | Secondary Licenses under the terms of this version of the License, the 334 | notice described in Exhibit B of this License must be attached. 335 | 336 | Exhibit A - Source Code Form License Notice 337 | 338 | This Source Code Form is subject to the 339 | terms of the Mozilla Public License, v. 340 | 2.0. If a copy of the MPL was not 341 | distributed with this file, You can 342 | obtain one at 343 | http://mozilla.org/MPL/2.0/. 344 | 345 | If it is not possible or desirable to put the notice in a particular file, then 346 | You may include the notice in a location (such as a LICENSE file in a relevant 347 | directory) where a recipient would be likely to look for such a notice. 348 | 349 | You may add additional accurate notices of copyright ownership. 350 | 351 | Exhibit B - “Incompatible With Secondary Licenses” Notice 352 | 353 | This Source Code Form is “Incompatible 354 | With Secondary Licenses”, as defined by 355 | the Mozilla Public License, v. 2.0. 356 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEST?=./... 2 | DEPS = $(shell go list -f '{{range .TestImports}}{{.}} {{end}}' ./...) 3 | 4 | all: deps build 5 | 6 | deps: 7 | go get -d -v ./... 8 | echo $(DEPS) | xargs -n1 go get -d 9 | 10 | build: 11 | @mkdir -p bin/ 12 | go build -o bin/atlas-go ./v1 13 | 14 | test: 15 | go test $(TEST) $(TESTARGS) -timeout=10s -parallel=4 16 | go vet $(TEST) 17 | go test $(TEST) -race 18 | 19 | .PHONY: all deps build test 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Atlas Go 2 | ======== 3 | [![Latest Version](http://img.shields.io/github/release/hashicorp/atlas-go.svg?style=flat-square)][release] 4 | [![Build Status](http://img.shields.io/travis/hashicorp/atlas-go.svg?style=flat-square)][travis] 5 | [![Go Documentation](http://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)][godocs] 6 | 7 | [release]: https://github.com/hashicorp/atlas-go/releases 8 | [travis]: http://travis-ci.org/hashicorp/atlas-go 9 | [godocs]: http://godoc.org/github.com/hashicorp/atlas-go 10 | 11 | Atlas Go is the official Go client for [HashiCorp's Atlas][Atlas] service. 12 | 13 | Usage 14 | ----- 15 | ### Authenticating with username and password 16 | Atlas Go can automatically generate an API authentication token given a username 17 | and password. For example: 18 | 19 | ```go 20 | client := atlas.DefaultClient() 21 | token, err := client.Login("username", "password") 22 | if err != nil { 23 | panic(err) 24 | } 25 | ``` 26 | 27 | The `Login` function returns an API token that can be used to sign requests. 28 | This function also sets the `Token` parameter on the Atlas Client, so future 29 | requests are signed with this access token. 30 | 31 | **If you have two-factor authentication enabled, you must manually generate an 32 | access token on the Atlas website.** 33 | 34 | ### Usage with on-premise Atlas 35 | Atlas Go supports on-premise Atlas installs, but you must specify the URL of the 36 | Atlas server in the client: 37 | 38 | ```go 39 | client, err := atlas.NewClient("https://url.to.your.atlas.server") 40 | if err != nil { 41 | panic(err) 42 | } 43 | ``` 44 | 45 | Example 46 | ------- 47 | The following example generates a new access token for a user named "sethvargo", 48 | generates a new Application named "frontend", and uploads the contents of a path 49 | to said application with some user-supplied metadata: 50 | 51 | ```go 52 | client := atlas.DefaultClient() 53 | token, err := client.Login("sethvargo", "b@c0n") 54 | if err != nil { 55 | log.Fatalf("err logging in: %s", err) 56 | } 57 | 58 | app, err := client.CreateApp("sethvargo", "frontend") 59 | if err != nil { 60 | log.Fatalf("err creating app: %s", err) 61 | } 62 | 63 | metadata := map[string]interface{ 64 | "developed-on": runtime.GOOS, 65 | } 66 | 67 | data, size := functionThatReturnsAnIOReaderAndSize() 68 | version, err := client.UploadApp(app, metadata, data, size) 69 | if err != nil { 70 | log.Fatalf("err uploading app: %s", err) 71 | } 72 | 73 | // version is the unique version of the application that was just uploaded 74 | version 75 | ``` 76 | 77 | 78 | FAQ 79 | --- 80 | **Q: Can I specify my token via an environment variable?**
81 | A: All of HashiCorp's products support the `ATLAS_TOKEN` environment variable. 82 | You can set this value in your shell profile or securely in your environment and 83 | it will be used. 84 | 85 | **Q: How can I authenticate if I have two-factor authentication enabled?**
86 | A: If you have two-factor authentication enabled, you must generate an access 87 | token via the Atlas website and pass it to the client initialization. The Atlas 88 | Go client does not support generating access tokens from two-factor 89 | authentication enabled accounts via the command line. 90 | 91 | **Q: Why do I need to specify the "user" for an Application, Build Configuration, 92 | and Runtime?**
93 | A: Since you can be a collaborator on different projects, we wanted to have 94 | absolute clarity around which artifact you are currently interacting with. 95 | 96 | 97 | Contributing 98 | ------------ 99 | To hack on Atlas Go, you will need a modern [Go][] environment. To compile the `atlas-go` binary and run the test suite, simply execute: 100 | 101 | ```shell 102 | $ make 103 | ``` 104 | 105 | This will compile the `atlas-go` binary into `bin/atlas-go` and run the test suite. 106 | 107 | If you just want to run the tests: 108 | 109 | ```shell 110 | $ make test 111 | ``` 112 | 113 | Or to run a specific test in the suite: 114 | 115 | ```shell 116 | go test ./... -run SomeTestFunction_name 117 | ``` 118 | 119 | Submit Pull Requests and Issues to the [Atlas Go project on GitHub][Atlas Go]. 120 | 121 | [Atlas]: https://atlas.hashicorp.com "HashiCorp's Atlas" 122 | [Atlas Go]: https://github.com/hashicorp/atlas-go "Atlas Go on GitHub" 123 | [Go]: http://golang.org "Go the language" 124 | -------------------------------------------------------------------------------- /archive/archive.go: -------------------------------------------------------------------------------- 1 | // archive is package that helps create archives in a format that 2 | // Atlas expects with its various upload endpoints. 3 | package archive 4 | 5 | import ( 6 | "archive/tar" 7 | "bufio" 8 | "compress/gzip" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "log" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | ) 17 | 18 | // Archive is the resulting archive. The archive data is generally streamed 19 | // so the io.ReadCloser can be used to backpressure the archive progress 20 | // and avoid memory pressure. 21 | type Archive struct { 22 | io.ReadCloser 23 | 24 | Size int64 25 | Metadata map[string]string 26 | } 27 | 28 | // ArchiveOpts are the options for defining how the archive will be built. 29 | type ArchiveOpts struct { 30 | // Exclude and Include are filters of files to include/exclude in 31 | // the archive when creating it from a directory. These filters should 32 | // be relative to the packaging directory and should be basic glob 33 | // patterns. 34 | Exclude []string 35 | Include []string 36 | 37 | // Extra is a mapping of extra files to include within the archive. The 38 | // key should be the path within the archive and the value should be 39 | // an absolute path to the file to put into the archive. These extra 40 | // files will override any other files in the archive. 41 | Extra map[string]string 42 | 43 | // VCS, if true, will detect and use a VCS system to determine what 44 | // files to include the archive. 45 | VCS bool 46 | } 47 | 48 | // IsSet says whether any options were set. 49 | func (o *ArchiveOpts) IsSet() bool { 50 | return len(o.Exclude) > 0 || len(o.Include) > 0 || o.VCS 51 | } 52 | 53 | // Constants related to setting special values for Extra in ArchiveOpts. 54 | const ( 55 | // ExtraEntryDir just creates the Extra key as a directory entry. 56 | ExtraEntryDir = "" 57 | ) 58 | 59 | // CreateArchive takes the given path and ArchiveOpts and archives it. 60 | // 61 | // The archive will be fully completed and put into a temporary file. 62 | // This must be done to retrieve the content length of the archive which 63 | // is needed for almost all operations involving archives with Atlas. Because 64 | // of this, sufficient disk space will be required to buffer the archive. 65 | func CreateArchive(path string, opts *ArchiveOpts) (*Archive, error) { 66 | log.Printf("[INFO] creating archive from %s", path) 67 | 68 | // Dereference any symlinks and determine the real path and info 69 | fi, err := os.Lstat(path) 70 | if err != nil { 71 | return nil, err 72 | } 73 | if fi.Mode()&os.ModeSymlink != 0 { 74 | path, fi, err = readLinkFull(path, fi) 75 | if err != nil { 76 | return nil, err 77 | } 78 | } 79 | 80 | // Windows 81 | path = filepath.ToSlash(path) 82 | 83 | // Direct file paths cannot have archive options 84 | if !fi.IsDir() && opts.IsSet() { 85 | return nil, fmt.Errorf( 86 | "options such as exclude, include, and VCS can't be set when " + 87 | "the path is a file.") 88 | } 89 | 90 | if fi.IsDir() { 91 | return archiveDir(path, opts) 92 | } else { 93 | return archiveFile(path) 94 | } 95 | } 96 | 97 | func archiveFile(path string) (*Archive, error) { 98 | f, err := os.Open(path) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | if _, err := gzip.NewReader(f); err == nil { 104 | // Reset the read offset for future reading 105 | if _, err := f.Seek(0, 0); err != nil { 106 | f.Close() 107 | return nil, err 108 | } 109 | 110 | // Get the file info for the size 111 | fi, err := f.Stat() 112 | if err != nil { 113 | f.Close() 114 | return nil, err 115 | } 116 | 117 | // This is a gzip file, let it through. 118 | return &Archive{ReadCloser: f, Size: fi.Size()}, nil 119 | } 120 | 121 | // Close the file, no use for it anymore 122 | f.Close() 123 | 124 | // We have a single file that is not gzipped. Compress it. 125 | path, err = filepath.Abs(path) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | // Act like we're compressing a directory, but only include this one 131 | // file. 132 | return archiveDir(filepath.Dir(path), &ArchiveOpts{ 133 | Include: []string{filepath.Base(path)}, 134 | }) 135 | } 136 | 137 | func archiveDir(root string, opts *ArchiveOpts) (*Archive, error) { 138 | 139 | var vcsInclude []string 140 | var metadata map[string]string 141 | if opts.VCS { 142 | var err error 143 | 144 | if err = vcsPreflight(root); err != nil { 145 | return nil, err 146 | } 147 | 148 | vcsInclude, err = vcsFiles(root) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | metadata, err = vcsMetadata(root) 154 | if err != nil { 155 | return nil, err 156 | } 157 | } 158 | 159 | // Make sure the root path is absolute 160 | root, err := filepath.Abs(root) 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | // Create the temporary file that we'll send the archive data to. 166 | archiveF, err := ioutil.TempFile("", "atlas-archive") 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | // Create the wrapper for the result which will automatically 172 | // remove the temporary file on close. 173 | archiveWrapper := &readCloseRemover{F: archiveF} 174 | 175 | // Buffer the writer so that we can push as much data to disk at 176 | // a time as possible. 4M should be good. 177 | bufW := bufio.NewWriterSize(archiveF, 4096*1024) 178 | 179 | // Gzip compress all the output data 180 | gzipW := gzip.NewWriter(bufW) 181 | 182 | // Tar the file contents 183 | tarW := tar.NewWriter(gzipW) 184 | 185 | // First, walk the path and do the normal files 186 | werr := filepath.Walk(root, copyDirWalkFn( 187 | tarW, root, "", opts, vcsInclude)) 188 | if werr == nil { 189 | // If that succeeded, handle the extra files 190 | werr = copyExtras(tarW, opts.Extra) 191 | } 192 | 193 | // Attempt to close all the things. If we get an error on the way 194 | // and we haven't had an error yet, then record that as the critical 195 | // error. But we still try to close everything. 196 | 197 | // Close the tar writer 198 | if err := tarW.Close(); err != nil && werr == nil { 199 | werr = err 200 | } 201 | 202 | // Close the gzip writer 203 | if err := gzipW.Close(); err != nil && werr == nil { 204 | werr = err 205 | } 206 | 207 | // Flush the buffer 208 | if err := bufW.Flush(); err != nil && werr == nil { 209 | werr = err 210 | } 211 | 212 | // If we had an error, then close the file (removing it) and 213 | // return the error. 214 | if werr != nil { 215 | archiveWrapper.Close() 216 | return nil, werr 217 | } 218 | 219 | // Seek to the beginning 220 | if _, err := archiveWrapper.F.Seek(0, 0); err != nil { 221 | archiveWrapper.Close() 222 | return nil, err 223 | } 224 | 225 | // Get the file information so we can get the size 226 | fi, err := archiveWrapper.F.Stat() 227 | if err != nil { 228 | archiveWrapper.Close() 229 | return nil, err 230 | } 231 | 232 | return &Archive{ 233 | ReadCloser: archiveWrapper, 234 | Size: fi.Size(), 235 | Metadata: metadata, 236 | }, nil 237 | } 238 | 239 | func copyDirWalkFn( 240 | tarW *tar.Writer, root string, prefix string, 241 | opts *ArchiveOpts, vcsInclude []string) filepath.WalkFunc { 242 | 243 | errFunc := func(err error) filepath.WalkFunc { 244 | return func(string, os.FileInfo, error) error { 245 | return err 246 | } 247 | } 248 | 249 | // Windows 250 | root = filepath.ToSlash(root) 251 | 252 | var includeMap map[string]struct{} 253 | 254 | // If we have an include/exclude pattern set, then setup the lookup 255 | // table to determine what we want to include. 256 | if opts != nil && len(opts.Include) > 0 { 257 | includeMap = make(map[string]struct{}) 258 | for _, pattern := range opts.Include { 259 | matches, err := filepath.Glob(filepath.Join(root, pattern)) 260 | if err != nil { 261 | return errFunc(fmt.Errorf( 262 | "error checking include glob '%s': %s", 263 | pattern, err)) 264 | } 265 | 266 | for _, path := range matches { 267 | // Windows 268 | path = filepath.ToSlash(path) 269 | subpath, err := filepath.Rel(root, path) 270 | subpath = filepath.ToSlash(subpath) 271 | 272 | if err != nil { 273 | return errFunc(err) 274 | } 275 | 276 | for { 277 | includeMap[subpath] = struct{}{} 278 | subpath = filepath.Dir(subpath) 279 | if subpath == "." { 280 | break 281 | } 282 | } 283 | } 284 | } 285 | } 286 | 287 | return func(path string, info os.FileInfo, err error) error { 288 | path = filepath.ToSlash(path) 289 | 290 | if err != nil { 291 | return err 292 | } 293 | 294 | // Get the relative path from the path since it contains the root 295 | // plus the path. 296 | subpath, err := filepath.Rel(root, path) 297 | if err != nil { 298 | return err 299 | } 300 | if subpath == "." { 301 | return nil 302 | } 303 | if prefix != "" { 304 | subpath = filepath.Join(prefix, subpath) 305 | } 306 | // Windows 307 | subpath = filepath.ToSlash(subpath) 308 | 309 | // If we have a list of VCS files, check that first 310 | skip := false 311 | if len(vcsInclude) > 0 { 312 | skip = true 313 | for _, f := range vcsInclude { 314 | if f == subpath { 315 | skip = false 316 | break 317 | } 318 | 319 | if info.IsDir() && strings.HasPrefix(f, subpath+"/") { 320 | skip = false 321 | break 322 | } 323 | } 324 | } 325 | 326 | // If include is present, we only include what is listed 327 | if len(includeMap) > 0 { 328 | if _, ok := includeMap[subpath]; !ok { 329 | skip = true 330 | } 331 | } 332 | 333 | // If exclude, it is one last gate to excluding files 334 | if opts != nil { 335 | for _, exclude := range opts.Exclude { 336 | match, err := filepath.Match(exclude, subpath) 337 | if err != nil { 338 | return err 339 | } 340 | if match { 341 | skip = true 342 | break 343 | } 344 | } 345 | } 346 | 347 | // If we have to skip this file, then skip it, properly skipping 348 | // children if we're a directory. 349 | if skip { 350 | if info.IsDir() { 351 | return filepath.SkipDir 352 | } 353 | 354 | return nil 355 | } 356 | 357 | // If this is a symlink, then we need to get the symlink target 358 | // rather than the symlink itself. 359 | if info.Mode()&os.ModeSymlink != 0 { 360 | target, info, err := readLinkFull(path, info) 361 | if err != nil { 362 | return err 363 | } 364 | 365 | // Copy the concrete entry for this path. This will either 366 | // be the file itself or just a directory entry. 367 | if err := copyConcreteEntry(tarW, subpath, target, info); err != nil { 368 | return err 369 | } 370 | 371 | if info.IsDir() { 372 | return filepath.Walk(target, copyDirWalkFn( 373 | tarW, target, subpath, opts, vcsInclude)) 374 | } 375 | // return now so that we don't try to copy twice 376 | return nil 377 | } 378 | 379 | return copyConcreteEntry(tarW, subpath, path, info) 380 | } 381 | } 382 | 383 | func copyConcreteEntry( 384 | tarW *tar.Writer, entry string, 385 | path string, info os.FileInfo) error { 386 | // Windows 387 | path = filepath.ToSlash(path) 388 | 389 | // Build the file header for the tar entry 390 | header, err := tar.FileInfoHeader(info, path) 391 | if err != nil { 392 | return fmt.Errorf( 393 | "failed creating archive header: %s", path) 394 | } 395 | 396 | // Modify the header to properly be the full entry name 397 | header.Name = entry 398 | if info.IsDir() { 399 | header.Name += "/" 400 | } 401 | 402 | // Write the header first to the archive. 403 | if err := tarW.WriteHeader(header); err != nil { 404 | return fmt.Errorf( 405 | "failed writing archive header: %s", path) 406 | } 407 | 408 | // If it is a directory, then we're done (no body to write) 409 | if info.IsDir() { 410 | return nil 411 | } 412 | 413 | // Open the real file to write the data 414 | f, err := os.Open(path) 415 | if err != nil { 416 | return fmt.Errorf( 417 | "failed opening file '%s' to write compressed archive.", path) 418 | } 419 | defer f.Close() 420 | 421 | if _, err = io.Copy(tarW, f); err != nil { 422 | return fmt.Errorf( 423 | "failed copying file to archive: %s, %s", path, err) 424 | } 425 | 426 | return nil 427 | } 428 | 429 | func copyExtras(w *tar.Writer, extra map[string]string) error { 430 | var tmpDir string 431 | defer func() { 432 | if tmpDir != "" { 433 | os.RemoveAll(tmpDir) 434 | } 435 | }() 436 | 437 | for entry, path := range extra { 438 | // If the path is empty, then we set it to a generic empty directory 439 | if path == "" { 440 | // If tmpDir is still empty, then we create an empty dir 441 | if tmpDir == "" { 442 | td, err := ioutil.TempDir("", "archive") 443 | if err != nil { 444 | return err 445 | } 446 | 447 | tmpDir = td 448 | } 449 | 450 | path = tmpDir 451 | } 452 | 453 | info, err := os.Stat(path) 454 | if err != nil { 455 | return err 456 | } 457 | 458 | // No matter what, write the entry. If this is a directory, 459 | // it'll just write the directory header. 460 | if err := copyConcreteEntry(w, entry, path, info); err != nil { 461 | return err 462 | } 463 | 464 | // If this is a directory, then we walk the internal contents 465 | // and copy those as well. 466 | if info.IsDir() { 467 | err := filepath.Walk(path, copyDirWalkFn( 468 | w, path, entry, nil, nil)) 469 | if err != nil { 470 | return err 471 | } 472 | } 473 | } 474 | 475 | return nil 476 | } 477 | 478 | func readLinkFull(path string, info os.FileInfo) (string, os.FileInfo, error) { 479 | target, err := filepath.EvalSymlinks(path) 480 | if err != nil { 481 | return "", nil, err 482 | } 483 | 484 | target, err = filepath.Abs(target) 485 | if err != nil { 486 | return "", nil, err 487 | } 488 | 489 | fi, err := os.Lstat(target) 490 | if err != nil { 491 | return "", nil, err 492 | } 493 | 494 | return target, fi, nil 495 | } 496 | 497 | // readCloseRemover is an io.ReadCloser implementation that will remove 498 | // the file on Close(). We use this to clean up our temporary file for 499 | // the archive. 500 | type readCloseRemover struct { 501 | F *os.File 502 | } 503 | 504 | func (r *readCloseRemover) Read(p []byte) (int, error) { 505 | return r.F.Read(p) 506 | } 507 | 508 | func (r *readCloseRemover) Close() error { 509 | // First close the file 510 | err := r.F.Close() 511 | 512 | // Next make sure to remove it, or at least try, regardless of error 513 | // above. 514 | os.Remove(r.F.Name()) 515 | 516 | return err 517 | } 518 | -------------------------------------------------------------------------------- /archive/archive_test.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "compress/gzip" 7 | "io" 8 | "io/ioutil" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "reflect" 13 | "runtime" 14 | "sort" 15 | "testing" 16 | ) 17 | 18 | const fixturesDir = "./test-fixtures" 19 | 20 | var testHasGit bool 21 | var testHasHg bool 22 | 23 | func init() { 24 | if _, err := exec.LookPath("git"); err == nil { 25 | testHasGit = true 26 | } 27 | 28 | if _, err := exec.LookPath("hg"); err == nil { 29 | testHasHg = true 30 | } 31 | } 32 | 33 | func TestArchiveOptsIsSet(t *testing.T) { 34 | cases := []struct { 35 | Opts *ArchiveOpts 36 | Set bool 37 | }{ 38 | { 39 | &ArchiveOpts{}, 40 | false, 41 | }, 42 | { 43 | &ArchiveOpts{VCS: true}, 44 | true, 45 | }, 46 | { 47 | &ArchiveOpts{Exclude: make([]string, 0, 0)}, 48 | false, 49 | }, 50 | { 51 | &ArchiveOpts{Exclude: []string{"foo"}}, 52 | true, 53 | }, 54 | { 55 | &ArchiveOpts{Include: make([]string, 0, 0)}, 56 | false, 57 | }, 58 | { 59 | &ArchiveOpts{Include: []string{"foo"}}, 60 | true, 61 | }, 62 | } 63 | 64 | for i, tc := range cases { 65 | if tc.Opts.IsSet() != tc.Set { 66 | t.Fatalf("%d: expected %#v", i, tc.Set) 67 | } 68 | } 69 | } 70 | 71 | func TestArchive_file(t *testing.T) { 72 | path := filepath.Join(testFixture("archive-file"), "foo.txt") 73 | r, err := CreateArchive(path, new(ArchiveOpts)) 74 | if err != nil { 75 | t.Fatalf("err: %s", err) 76 | } 77 | 78 | expected := []string{ 79 | "foo.txt", 80 | } 81 | 82 | entries := testArchive(t, r, false) 83 | if !reflect.DeepEqual(entries, expected) { 84 | t.Fatalf("bad: %#v", entries) 85 | } 86 | } 87 | 88 | func TestArchive_fileCompressed(t *testing.T) { 89 | path := filepath.Join(testFixture("archive-file-compressed"), "file.tar.gz") 90 | r, err := CreateArchive(path, new(ArchiveOpts)) 91 | if err != nil { 92 | t.Fatalf("err: %s", err) 93 | } 94 | 95 | expected := []string{ 96 | "./foo.txt", 97 | } 98 | 99 | entries := testArchive(t, r, false) 100 | if !reflect.DeepEqual(entries, expected) { 101 | t.Fatalf("bad: %#v", entries) 102 | } 103 | } 104 | 105 | func TestArchive_fileNoExist(t *testing.T) { 106 | tf := tempFile(t) 107 | if err := os.Remove(tf); err != nil { 108 | t.Fatalf("err: %s", err) 109 | } 110 | 111 | r, err := CreateArchive(tf, nil) 112 | if err == nil { 113 | t.Fatal("err should not be nil") 114 | } 115 | if r != nil { 116 | t.Fatal("should be nil") 117 | } 118 | } 119 | 120 | func TestArchive_fileWithOpts(t *testing.T) { 121 | r, err := CreateArchive(tempFile(t), &ArchiveOpts{VCS: true}) 122 | if err == nil { 123 | t.Fatal("err should not be nil") 124 | } 125 | if r != nil { 126 | t.Fatal("should be nil") 127 | } 128 | } 129 | 130 | func TestArchive_dirExtra(t *testing.T) { 131 | opts := &ArchiveOpts{ 132 | Extra: map[string]string{ 133 | "hello.txt": filepath.Join( 134 | testFixture("archive-subdir"), "subdir", "hello.txt"), 135 | }, 136 | } 137 | 138 | r, err := CreateArchive(testFixture("archive-flat"), opts) 139 | if err != nil { 140 | t.Fatalf("err: %s", err) 141 | } 142 | 143 | expected := []string{ 144 | "baz.txt", 145 | "foo.txt", 146 | "hello.txt", 147 | } 148 | 149 | entries := testArchive(t, r, false) 150 | if !reflect.DeepEqual(entries, expected) { 151 | t.Fatalf("bad: %#v", entries) 152 | } 153 | } 154 | 155 | func TestArchive_dirExtraDir(t *testing.T) { 156 | opts := &ArchiveOpts{ 157 | Extra: map[string]string{ 158 | "foo": filepath.Join(testFixture("archive-subdir"), "subdir"), 159 | }, 160 | } 161 | 162 | r, err := CreateArchive(testFixture("archive-flat"), opts) 163 | if err != nil { 164 | t.Fatalf("err: %s", err) 165 | } 166 | 167 | expected := []string{ 168 | "baz.txt", 169 | "foo.txt", 170 | "foo/", 171 | "foo/hello.txt", 172 | } 173 | 174 | entries := testArchive(t, r, false) 175 | if !reflect.DeepEqual(entries, expected) { 176 | t.Fatalf("bad: %#v", entries) 177 | } 178 | } 179 | 180 | func TestArchive_dirExtraDirHeader(t *testing.T) { 181 | opts := &ArchiveOpts{ 182 | Extra: map[string]string{ 183 | "foo": ExtraEntryDir, 184 | }, 185 | } 186 | 187 | r, err := CreateArchive(testFixture("archive-flat"), opts) 188 | if err != nil { 189 | t.Fatalf("err: %s", err) 190 | } 191 | 192 | expected := []string{ 193 | "baz.txt", 194 | "foo.txt", 195 | "foo/", 196 | } 197 | 198 | entries := testArchive(t, r, false) 199 | if !reflect.DeepEqual(entries, expected) { 200 | t.Fatalf("bad: %#v", entries) 201 | } 202 | } 203 | 204 | func TestArchive_dirMode(t *testing.T) { 205 | if runtime.GOOS == "windows" { 206 | t.Skip("modes don't work on Windows") 207 | } 208 | 209 | opts := &ArchiveOpts{} 210 | 211 | r, err := CreateArchive(testFixture("archive-dir-mode"), opts) 212 | if err != nil { 213 | t.Fatalf("err: %s", err) 214 | } 215 | 216 | expected := []string{ 217 | "file.txt-exec", 218 | } 219 | 220 | entries := testArchive(t, r, true) 221 | if !reflect.DeepEqual(entries, expected) { 222 | t.Fatalf("bad: %#v", entries) 223 | } 224 | } 225 | func TestArchive_dirSymlink(t *testing.T) { 226 | if runtime.GOOS == "windows" { 227 | t.Skip("git symlinks don't work on Windows") 228 | } 229 | 230 | path := filepath.Join(testFixture("archive-symlink"), "link", "link") 231 | r, err := CreateArchive(path, new(ArchiveOpts)) 232 | if err != nil { 233 | t.Fatalf("err: %s", err) 234 | } 235 | 236 | expected := []string{ 237 | "foo.txt", 238 | } 239 | 240 | entries := testArchive(t, r, false) 241 | if !reflect.DeepEqual(entries, expected) { 242 | t.Fatalf("bad: %#v", entries) 243 | } 244 | } 245 | 246 | func TestArchive_dirWithSymlink(t *testing.T) { 247 | if runtime.GOOS == "windows" { 248 | t.Skip("git symlinks don't work on Windows") 249 | } 250 | 251 | path := filepath.Join(testFixture("archive-symlink"), "link") 252 | r, err := CreateArchive(path, new(ArchiveOpts)) 253 | if err != nil { 254 | t.Fatalf("err: %s", err) 255 | } 256 | 257 | expected := []string{ 258 | "link/", 259 | "link/foo.txt", 260 | } 261 | 262 | entries := testArchive(t, r, false) 263 | if !reflect.DeepEqual(entries, expected) { 264 | t.Fatalf("bad: %#v", entries) 265 | } 266 | } 267 | 268 | func TestArchive_dirWithSymlinkToFile(t *testing.T) { 269 | if runtime.GOOS == "windows" { 270 | t.Skip("git symlinks don't work on Windows") 271 | } 272 | 273 | path := filepath.Join(testFixture("archive-symlink-file"), "link") 274 | r, err := CreateArchive(path, new(ArchiveOpts)) 275 | if err != nil { 276 | t.Fatalf("err: %s", err) 277 | } 278 | 279 | expected := []string{ 280 | "deeper/", 281 | "deeper/adeeperlink", 282 | "deeper/linklink", 283 | "deeper/linklinklink", 284 | "link", 285 | } 286 | 287 | entries := testArchive(t, r, false) 288 | if !reflect.DeepEqual(entries, expected) { 289 | t.Fatalf("bad: %#v", entries) 290 | } 291 | } 292 | 293 | func TestArchive_dirNoVCS(t *testing.T) { 294 | r, err := CreateArchive(testFixture("archive-flat"), new(ArchiveOpts)) 295 | if err != nil { 296 | t.Fatalf("err: %s", err) 297 | } 298 | 299 | expected := []string{ 300 | "baz.txt", 301 | "foo.txt", 302 | } 303 | 304 | entries := testArchive(t, r, false) 305 | if !reflect.DeepEqual(entries, expected) { 306 | t.Fatalf("bad: %#v", entries) 307 | } 308 | } 309 | 310 | func TestArchive_dirSubdirsNoVCS(t *testing.T) { 311 | r, err := CreateArchive(testFixture("archive-subdir"), new(ArchiveOpts)) 312 | if err != nil { 313 | t.Fatalf("err: %s", err) 314 | } 315 | 316 | expected := []string{ 317 | "bar.txt", 318 | "foo.txt", 319 | "subdir/", 320 | "subdir/hello.txt", 321 | } 322 | 323 | entries := testArchive(t, r, false) 324 | if !reflect.DeepEqual(entries, expected) { 325 | t.Fatalf("bad: %#v", entries) 326 | } 327 | } 328 | 329 | func TestArchive_dirExclude(t *testing.T) { 330 | opts := &ArchiveOpts{ 331 | Exclude: []string{"subdir", "subdir/*"}, 332 | } 333 | 334 | r, err := CreateArchive(testFixture("archive-subdir"), opts) 335 | if err != nil { 336 | t.Fatalf("err: %s", err) 337 | } 338 | 339 | expected := []string{ 340 | "bar.txt", 341 | "foo.txt", 342 | } 343 | 344 | entries := testArchive(t, r, false) 345 | if !reflect.DeepEqual(entries, expected) { 346 | t.Fatalf("bad: %#v", entries) 347 | } 348 | } 349 | 350 | func TestArchive_dirInclude(t *testing.T) { 351 | opts := &ArchiveOpts{ 352 | Include: []string{"bar.txt"}, 353 | } 354 | 355 | r, err := CreateArchive(testFixture("archive-subdir"), opts) 356 | if err != nil { 357 | t.Fatalf("err: %s", err) 358 | } 359 | 360 | expected := []string{ 361 | "bar.txt", 362 | } 363 | 364 | entries := testArchive(t, r, false) 365 | if !reflect.DeepEqual(entries, expected) { 366 | t.Fatalf("bad: %#v", entries) 367 | } 368 | } 369 | 370 | func TestArchive_dirIncludeStar(t *testing.T) { 371 | opts := &ArchiveOpts{ 372 | Include: []string{"build/**/*"}, 373 | } 374 | 375 | r, err := CreateArchive(testFixture("archive-subdir-splat"), opts) 376 | if err != nil { 377 | t.Fatalf("err: %s", err) 378 | } 379 | 380 | expected := []string{ 381 | "build/", 382 | "build/darwin-amd64/", 383 | "build/darwin-amd64/build.txt", 384 | "build/linux-amd64/", 385 | "build/linux-amd64/build.txt", 386 | } 387 | 388 | entries := testArchive(t, r, false) 389 | if !reflect.DeepEqual(entries, expected) { 390 | t.Fatalf("bad: %#v", entries) 391 | } 392 | } 393 | 394 | func TestArchive_git(t *testing.T) { 395 | if !testHasGit { 396 | t.Log("git not found, skipping") 397 | t.Skip() 398 | } 399 | 400 | // Git doesn't allow nested ".git" directories so we do some hackiness 401 | // here to get around that... 402 | testDir := testFixture("archive-git") 403 | oldName := filepath.ToSlash(filepath.Join(testDir, "DOTgit")) 404 | newName := filepath.ToSlash(filepath.Join(testDir, ".git")) 405 | os.Remove(newName) 406 | if err := os.Rename(oldName, newName); err != nil { 407 | t.Fatalf("err: %s", err) 408 | } 409 | defer os.Rename(newName, oldName) 410 | 411 | // testDir with VCS set to true 412 | r, err := CreateArchive(testDir, &ArchiveOpts{VCS: true}) 413 | if err != nil { 414 | t.Fatalf("err: %s", err) 415 | } 416 | 417 | expected := []string{ 418 | "bar.txt", 419 | "foo.txt", 420 | "subdir/", 421 | "subdir/hello.txt", 422 | } 423 | 424 | entries := testArchive(t, r, false) 425 | if !reflect.DeepEqual(entries, expected) { 426 | t.Fatalf("bad: %#v", entries) 427 | } 428 | 429 | // Test that metadata was added 430 | if r.Metadata == nil { 431 | t.Fatal("expected archive metadata to be set") 432 | } 433 | 434 | expectedMetadata := map[string]string{ 435 | "branch": "master", 436 | "commit": "7525d17cbbb56f3253a20903ffddc07c6c935c76", 437 | "remote.origin": "https://github.com/hashicorp/origin.git", 438 | "remote.upstream": "https://github.com/hashicorp/upstream.git", 439 | } 440 | 441 | if !reflect.DeepEqual(r.Metadata, expectedMetadata) { 442 | t.Fatalf("expected %+v to be %+v", r.Metadata, expectedMetadata) 443 | } 444 | } 445 | 446 | func TestArchive_gitSubdir(t *testing.T) { 447 | if !testHasGit { 448 | t.Log("git not found, skipping") 449 | t.Skip() 450 | } 451 | 452 | // Git doesn't allow nested ".git" directories so we do some hackiness 453 | // here to get around that... 454 | testDir := testFixture("archive-git") 455 | oldName := filepath.ToSlash(filepath.Join(testDir, "DOTgit")) 456 | newName := filepath.ToSlash(filepath.Join(testDir, ".git")) 457 | os.Remove(newName) 458 | if err := os.Rename(oldName, newName); err != nil { 459 | t.Fatalf("err: %s", err) 460 | } 461 | defer os.Rename(newName, oldName) 462 | 463 | // testDir with VCS set to true 464 | r, err := CreateArchive(filepath.Join(testDir, "subdir"), &ArchiveOpts{VCS: true}) 465 | if err != nil { 466 | t.Fatalf("err: %s", err) 467 | } 468 | 469 | expected := []string{ 470 | "hello.txt", 471 | } 472 | 473 | entries := testArchive(t, r, false) 474 | if !reflect.DeepEqual(entries, expected) { 475 | t.Fatalf("bad: %#v", entries) 476 | } 477 | } 478 | 479 | func TestArchive_hg(t *testing.T) { 480 | if !testHasHg { 481 | t.Log("hg not found, skipping") 482 | t.Skip() 483 | } 484 | 485 | // testDir with VCS set to true 486 | testDir := testFixture("archive-hg") 487 | r, err := CreateArchive(testDir, &ArchiveOpts{VCS: true}) 488 | if err != nil { 489 | t.Fatalf("err: %s", err) 490 | } 491 | 492 | expected := []string{ 493 | "bar.txt", 494 | "foo.txt", 495 | "subdir/", 496 | "subdir/hello.txt", 497 | } 498 | 499 | entries := testArchive(t, r, false) 500 | if !reflect.DeepEqual(entries, expected) { 501 | t.Fatalf("\n-- Expected --\n%#v\n-- Found --\n%#v", expected, entries) 502 | } 503 | } 504 | 505 | func TestArchive_hgSubdir(t *testing.T) { 506 | if !testHasHg { 507 | t.Log("hg not found, skipping") 508 | t.Skip() 509 | } 510 | 511 | // testDir with VCS set to true 512 | testDir := filepath.Join(testFixture("archive-hg"), "subdir") 513 | r, err := CreateArchive(testDir, &ArchiveOpts{VCS: true}) 514 | if err != nil { 515 | t.Fatalf("err: %s", err) 516 | } 517 | 518 | expected := []string{ 519 | "hello.txt", 520 | } 521 | 522 | entries := testArchive(t, r, false) 523 | if !reflect.DeepEqual(entries, expected) { 524 | t.Fatalf("\n-- Expected --\n%#v\n-- Found --\n%#v", expected, entries) 525 | } 526 | } 527 | 528 | func TestReadCloseRemover(t *testing.T) { 529 | f, err := ioutil.TempFile("", "atlas-go") 530 | if err != nil { 531 | t.Fatalf("err: %s", err) 532 | } 533 | 534 | r := &readCloseRemover{F: f} 535 | if err := r.Close(); err != nil { 536 | t.Fatalf("err: %s", err) 537 | } 538 | 539 | if _, err := os.Stat(f.Name()); err == nil { 540 | t.Fatal("file should not exist anymore") 541 | } 542 | } 543 | 544 | func testArchive(t *testing.T, r *Archive, detailed bool) []string { 545 | // Finish the archiving process in-memory 546 | var buf bytes.Buffer 547 | n, err := io.Copy(&buf, r) 548 | if err != nil { 549 | t.Fatalf("err: %s", err) 550 | } 551 | if n != r.Size { 552 | t.Fatalf("bad size: %d (expected: %d)", n, r.Size) 553 | } 554 | 555 | gzipR, err := gzip.NewReader(&buf) 556 | if err != nil { 557 | t.Fatalf("err: %s", err) 558 | } 559 | tarR := tar.NewReader(gzipR) 560 | 561 | // Read all the entries 562 | result := make([]string, 0, 5) 563 | for { 564 | hdr, err := tarR.Next() 565 | if err == io.EOF { 566 | break 567 | } 568 | if err != nil { 569 | t.Fatalf("err: %s", err) 570 | } 571 | 572 | text := hdr.Name 573 | if detailed { 574 | // Check if the file is executable. We use these stub names 575 | // to compensate for umask differences in test environments 576 | // and limitations in using "git clone". 577 | if hdr.FileInfo().Mode()&0111 != 0 { 578 | text = hdr.Name + "-exec" 579 | } else { 580 | text = hdr.Name + "-reg" 581 | } 582 | } 583 | 584 | result = append(result, text) 585 | } 586 | 587 | sort.Strings(result) 588 | return result 589 | } 590 | 591 | func tempFile(t *testing.T) string { 592 | tf, err := ioutil.TempFile("", "test") 593 | if err != nil { 594 | t.Fatalf("err: %s", err) 595 | } 596 | defer tf.Close() 597 | 598 | return tf.Name() 599 | } 600 | 601 | func testFixture(n string) string { 602 | return filepath.Join(fixturesDir, n) 603 | } 604 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-dir-mode/file.txt: -------------------------------------------------------------------------------- 1 | I should be mode 0777 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-file-compressed/file.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-file-compressed/file.tar.gz -------------------------------------------------------------------------------- /archive/test-fixtures/archive-file/foo.txt: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-flat/baz.txt: -------------------------------------------------------------------------------- 1 | baz 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-flat/foo.txt: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/COMMIT_EDITMSG: -------------------------------------------------------------------------------- 1 | Those files tho 2 | # Please enter the commit message for your changes. Lines starting 3 | # with '#' will be ignored, and an empty message aborts the commit. 4 | # On branch master 5 | # 6 | # Initial commit 7 | # 8 | # Changes to be committed: 9 | # new file: bar.txt 10 | # new file: foo.txt 11 | # new file: subdir/hello.txt 12 | # 13 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/master 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/config: -------------------------------------------------------------------------------- 1 | [core] 2 | repositoryformatversion = 0 3 | filemode = true 4 | bare = false 5 | logallrefupdates = true 6 | ignorecase = true 7 | precomposeunicode = true 8 | [remote "origin"] 9 | url = https://github.com/hashicorp/origin.git 10 | fetch = +refs/heads/*:refs/remotes/origin/* 11 | [remote "upstream"] 12 | url = https://github.com/hashicorp/upstream.git 13 | fetch = +refs/heads/*:refs/remotes/upstream/* 14 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/description: -------------------------------------------------------------------------------- 1 | Unnamed repository; edit this file 'description' to name the repository. 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/hooks/applypatch-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message taken by 4 | # applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. The hook is 8 | # allowed to edit the commit message file. 9 | # 10 | # To enable this hook, rename this file to "applypatch-msg". 11 | 12 | . git-sh-setup 13 | test -x "$GIT_DIR/hooks/commit-msg" && 14 | exec "$GIT_DIR/hooks/commit-msg" ${1+"$@"} 15 | : 16 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/hooks/commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message. 4 | # Called by "git commit" with one argument, the name of the file 5 | # that has the commit message. The hook should exit with non-zero 6 | # status after issuing an appropriate message if it wants to stop the 7 | # commit. The hook is allowed to edit the commit message file. 8 | # 9 | # To enable this hook, rename this file to "commit-msg". 10 | 11 | # Uncomment the below to add a Signed-off-by line to the message. 12 | # Doing this in a hook is a bad idea in general, but the prepare-commit-msg 13 | # hook is more suited to it. 14 | # 15 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 16 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" 17 | 18 | # This example catches duplicate Signed-off-by lines. 19 | 20 | test "" = "$(grep '^Signed-off-by: ' "$1" | 21 | sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { 22 | echo >&2 Duplicate Signed-off-by lines. 23 | exit 1 24 | } 25 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/hooks/post-update.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare a packed repository for use over 4 | # dumb transports. 5 | # 6 | # To enable this hook, rename this file to "post-update". 7 | 8 | exec git update-server-info 9 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/hooks/pre-applypatch.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed 4 | # by applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. 8 | # 9 | # To enable this hook, rename this file to "pre-applypatch". 10 | 11 | . git-sh-setup 12 | test -x "$GIT_DIR/hooks/pre-commit" && 13 | exec "$GIT_DIR/hooks/pre-commit" ${1+"$@"} 14 | : 15 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/hooks/pre-commit.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed. 4 | # Called by "git commit" with no arguments. The hook should 5 | # exit with non-zero status after issuing an appropriate message if 6 | # it wants to stop the commit. 7 | # 8 | # To enable this hook, rename this file to "pre-commit". 9 | 10 | if git rev-parse --verify HEAD >/dev/null 2>&1 11 | then 12 | against=HEAD 13 | else 14 | # Initial commit: diff against an empty tree object 15 | against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 16 | fi 17 | 18 | # If you want to allow non-ASCII filenames set this variable to true. 19 | allownonascii=$(git config --bool hooks.allownonascii) 20 | 21 | # Redirect output to stderr. 22 | exec 1>&2 23 | 24 | # Cross platform projects tend to avoid non-ASCII filenames; prevent 25 | # them from being added to the repository. We exploit the fact that the 26 | # printable range starts at the space character and ends with tilde. 27 | if [ "$allownonascii" != "true" ] && 28 | # Note that the use of brackets around a tr range is ok here, (it's 29 | # even required, for portability to Solaris 10's /usr/bin/tr), since 30 | # the square bracket bytes happen to fall in the designated range. 31 | test $(git diff --cached --name-only --diff-filter=A -z $against | 32 | LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 33 | then 34 | cat <<\EOF 35 | Error: Attempt to add a non-ASCII file name. 36 | 37 | This can cause problems if you want to work with people on other platforms. 38 | 39 | To be portable it is advisable to rename the file. 40 | 41 | If you know what you are doing you can disable this check using: 42 | 43 | git config hooks.allownonascii true 44 | EOF 45 | exit 1 46 | fi 47 | 48 | # If there are whitespace errors, print the offending file names and fail. 49 | exec git diff-index --check --cached $against -- 50 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/hooks/pre-push.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # An example hook script to verify what is about to be pushed. Called by "git 4 | # push" after it has checked the remote status, but before anything has been 5 | # pushed. If this script exits with a non-zero status nothing will be pushed. 6 | # 7 | # This hook is called with the following parameters: 8 | # 9 | # $1 -- Name of the remote to which the push is being done 10 | # $2 -- URL to which the push is being done 11 | # 12 | # If pushing without using a named remote those arguments will be equal. 13 | # 14 | # Information about the commits which are being pushed is supplied as lines to 15 | # the standard input in the form: 16 | # 17 | # 18 | # 19 | # This sample shows how to prevent push of commits where the log message starts 20 | # with "WIP" (work in progress). 21 | 22 | remote="$1" 23 | url="$2" 24 | 25 | z40=0000000000000000000000000000000000000000 26 | 27 | IFS=' ' 28 | while read local_ref local_sha remote_ref remote_sha 29 | do 30 | if [ "$local_sha" = $z40 ] 31 | then 32 | # Handle delete 33 | : 34 | else 35 | if [ "$remote_sha" = $z40 ] 36 | then 37 | # New branch, examine all commits 38 | range="$local_sha" 39 | else 40 | # Update to existing branch, examine new commits 41 | range="$remote_sha..$local_sha" 42 | fi 43 | 44 | # Check for WIP commit 45 | commit=`git rev-list -n 1 --grep '^WIP' "$range"` 46 | if [ -n "$commit" ] 47 | then 48 | echo "Found WIP commit in $local_ref, not pushing" 49 | exit 1 50 | fi 51 | fi 52 | done 53 | 54 | exit 0 55 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/hooks/pre-rebase.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright (c) 2006, 2008 Junio C Hamano 4 | # 5 | # The "pre-rebase" hook is run just before "git rebase" starts doing 6 | # its job, and can prevent the command from running by exiting with 7 | # non-zero status. 8 | # 9 | # The hook is called with the following parameters: 10 | # 11 | # $1 -- the upstream the series was forked from. 12 | # $2 -- the branch being rebased (or empty when rebasing the current branch). 13 | # 14 | # This sample shows how to prevent topic branches that are already 15 | # merged to 'next' branch from getting rebased, because allowing it 16 | # would result in rebasing already published history. 17 | 18 | publish=next 19 | basebranch="$1" 20 | if test "$#" = 2 21 | then 22 | topic="refs/heads/$2" 23 | else 24 | topic=`git symbolic-ref HEAD` || 25 | exit 0 ;# we do not interrupt rebasing detached HEAD 26 | fi 27 | 28 | case "$topic" in 29 | refs/heads/??/*) 30 | ;; 31 | *) 32 | exit 0 ;# we do not interrupt others. 33 | ;; 34 | esac 35 | 36 | # Now we are dealing with a topic branch being rebased 37 | # on top of master. Is it OK to rebase it? 38 | 39 | # Does the topic really exist? 40 | git show-ref -q "$topic" || { 41 | echo >&2 "No such branch $topic" 42 | exit 1 43 | } 44 | 45 | # Is topic fully merged to master? 46 | not_in_master=`git rev-list --pretty=oneline ^master "$topic"` 47 | if test -z "$not_in_master" 48 | then 49 | echo >&2 "$topic is fully merged to master; better remove it." 50 | exit 1 ;# we could allow it, but there is no point. 51 | fi 52 | 53 | # Is topic ever merged to next? If so you should not be rebasing it. 54 | only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` 55 | only_next_2=`git rev-list ^master ${publish} | sort` 56 | if test "$only_next_1" = "$only_next_2" 57 | then 58 | not_in_topic=`git rev-list "^$topic" master` 59 | if test -z "$not_in_topic" 60 | then 61 | echo >&2 "$topic is already up-to-date with master" 62 | exit 1 ;# we could allow it, but there is no point. 63 | else 64 | exit 0 65 | fi 66 | else 67 | not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` 68 | /usr/bin/perl -e ' 69 | my $topic = $ARGV[0]; 70 | my $msg = "* $topic has commits already merged to public branch:\n"; 71 | my (%not_in_next) = map { 72 | /^([0-9a-f]+) /; 73 | ($1 => 1); 74 | } split(/\n/, $ARGV[1]); 75 | for my $elem (map { 76 | /^([0-9a-f]+) (.*)$/; 77 | [$1 => $2]; 78 | } split(/\n/, $ARGV[2])) { 79 | if (!exists $not_in_next{$elem->[0]}) { 80 | if ($msg) { 81 | print STDERR $msg; 82 | undef $msg; 83 | } 84 | print STDERR " $elem->[1]\n"; 85 | } 86 | } 87 | ' "$topic" "$not_in_next" "$not_in_master" 88 | exit 1 89 | fi 90 | 91 | exit 0 92 | 93 | ################################################################ 94 | 95 | This sample hook safeguards topic branches that have been 96 | published from being rewound. 97 | 98 | The workflow assumed here is: 99 | 100 | * Once a topic branch forks from "master", "master" is never 101 | merged into it again (either directly or indirectly). 102 | 103 | * Once a topic branch is fully cooked and merged into "master", 104 | it is deleted. If you need to build on top of it to correct 105 | earlier mistakes, a new topic branch is created by forking at 106 | the tip of the "master". This is not strictly necessary, but 107 | it makes it easier to keep your history simple. 108 | 109 | * Whenever you need to test or publish your changes to topic 110 | branches, merge them into "next" branch. 111 | 112 | The script, being an example, hardcodes the publish branch name 113 | to be "next", but it is trivial to make it configurable via 114 | $GIT_DIR/config mechanism. 115 | 116 | With this workflow, you would want to know: 117 | 118 | (1) ... if a topic branch has ever been merged to "next". Young 119 | topic branches can have stupid mistakes you would rather 120 | clean up before publishing, and things that have not been 121 | merged into other branches can be easily rebased without 122 | affecting other people. But once it is published, you would 123 | not want to rewind it. 124 | 125 | (2) ... if a topic branch has been fully merged to "master". 126 | Then you can delete it. More importantly, you should not 127 | build on top of it -- other people may already want to 128 | change things related to the topic as patches against your 129 | "master", so if you need further changes, it is better to 130 | fork the topic (perhaps with the same name) afresh from the 131 | tip of "master". 132 | 133 | Let's look at this example: 134 | 135 | o---o---o---o---o---o---o---o---o---o "next" 136 | / / / / 137 | / a---a---b A / / 138 | / / / / 139 | / / c---c---c---c B / 140 | / / / \ / 141 | / / / b---b C \ / 142 | / / / / \ / 143 | ---o---o---o---o---o---o---o---o---o---o---o "master" 144 | 145 | 146 | A, B and C are topic branches. 147 | 148 | * A has one fix since it was merged up to "next". 149 | 150 | * B has finished. It has been fully merged up to "master" and "next", 151 | and is ready to be deleted. 152 | 153 | * C has not merged to "next" at all. 154 | 155 | We would want to allow C to be rebased, refuse A, and encourage 156 | B to be deleted. 157 | 158 | To compute (1): 159 | 160 | git rev-list ^master ^topic next 161 | git rev-list ^master next 162 | 163 | if these match, topic has not merged in next at all. 164 | 165 | To compute (2): 166 | 167 | git rev-list master..topic 168 | 169 | if this is empty, it is fully merged to "master". 170 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/hooks/prepare-commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare the commit log message. 4 | # Called by "git commit" with the name of the file that has the 5 | # commit message, followed by the description of the commit 6 | # message's source. The hook's purpose is to edit the commit 7 | # message file. If the hook fails with a non-zero status, 8 | # the commit is aborted. 9 | # 10 | # To enable this hook, rename this file to "prepare-commit-msg". 11 | 12 | # This hook includes three examples. The first comments out the 13 | # "Conflicts:" part of a merge commit. 14 | # 15 | # The second includes the output of "git diff --name-status -r" 16 | # into the message, just before the "git status" output. It is 17 | # commented because it doesn't cope with --amend or with squashed 18 | # commits. 19 | # 20 | # The third example adds a Signed-off-by line to the message, that can 21 | # still be edited. This is rarely a good idea. 22 | 23 | case "$2,$3" in 24 | merge,) 25 | /usr/bin/perl -i.bak -ne 's/^/# /, s/^# #/#/ if /^Conflicts/ .. /#/; print' "$1" ;; 26 | 27 | # ,|template,) 28 | # /usr/bin/perl -i.bak -pe ' 29 | # print "\n" . `git diff --cached --name-status -r` 30 | # if /^#/ && $first++ == 0' "$1" ;; 31 | 32 | *) ;; 33 | esac 34 | 35 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 36 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" 37 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/hooks/update.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to blocks unannotated tags from entering. 4 | # Called by "git receive-pack" with arguments: refname sha1-old sha1-new 5 | # 6 | # To enable this hook, rename this file to "update". 7 | # 8 | # Config 9 | # ------ 10 | # hooks.allowunannotated 11 | # This boolean sets whether unannotated tags will be allowed into the 12 | # repository. By default they won't be. 13 | # hooks.allowdeletetag 14 | # This boolean sets whether deleting tags will be allowed in the 15 | # repository. By default they won't be. 16 | # hooks.allowmodifytag 17 | # This boolean sets whether a tag may be modified after creation. By default 18 | # it won't be. 19 | # hooks.allowdeletebranch 20 | # This boolean sets whether deleting branches will be allowed in the 21 | # repository. By default they won't be. 22 | # hooks.denycreatebranch 23 | # This boolean sets whether remotely creating branches will be denied 24 | # in the repository. By default this is allowed. 25 | # 26 | 27 | # --- Command line 28 | refname="$1" 29 | oldrev="$2" 30 | newrev="$3" 31 | 32 | # --- Safety check 33 | if [ -z "$GIT_DIR" ]; then 34 | echo "Don't run this script from the command line." >&2 35 | echo " (if you want, you could supply GIT_DIR then run" >&2 36 | echo " $0 )" >&2 37 | exit 1 38 | fi 39 | 40 | if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then 41 | echo "usage: $0 " >&2 42 | exit 1 43 | fi 44 | 45 | # --- Config 46 | allowunannotated=$(git config --bool hooks.allowunannotated) 47 | allowdeletebranch=$(git config --bool hooks.allowdeletebranch) 48 | denycreatebranch=$(git config --bool hooks.denycreatebranch) 49 | allowdeletetag=$(git config --bool hooks.allowdeletetag) 50 | allowmodifytag=$(git config --bool hooks.allowmodifytag) 51 | 52 | # check for no description 53 | projectdesc=$(sed -e '1q' "$GIT_DIR/description") 54 | case "$projectdesc" in 55 | "Unnamed repository"* | "") 56 | echo "*** Project description file hasn't been set" >&2 57 | exit 1 58 | ;; 59 | esac 60 | 61 | # --- Check types 62 | # if $newrev is 0000...0000, it's a commit to delete a ref. 63 | zero="0000000000000000000000000000000000000000" 64 | if [ "$newrev" = "$zero" ]; then 65 | newrev_type=delete 66 | else 67 | newrev_type=$(git cat-file -t $newrev) 68 | fi 69 | 70 | case "$refname","$newrev_type" in 71 | refs/tags/*,commit) 72 | # un-annotated tag 73 | short_refname=${refname##refs/tags/} 74 | if [ "$allowunannotated" != "true" ]; then 75 | echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 76 | echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 77 | exit 1 78 | fi 79 | ;; 80 | refs/tags/*,delete) 81 | # delete tag 82 | if [ "$allowdeletetag" != "true" ]; then 83 | echo "*** Deleting a tag is not allowed in this repository" >&2 84 | exit 1 85 | fi 86 | ;; 87 | refs/tags/*,tag) 88 | # annotated tag 89 | if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 90 | then 91 | echo "*** Tag '$refname' already exists." >&2 92 | echo "*** Modifying a tag is not allowed in this repository." >&2 93 | exit 1 94 | fi 95 | ;; 96 | refs/heads/*,commit) 97 | # branch 98 | if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then 99 | echo "*** Creating a branch is not allowed in this repository" >&2 100 | exit 1 101 | fi 102 | ;; 103 | refs/heads/*,delete) 104 | # delete branch 105 | if [ "$allowdeletebranch" != "true" ]; then 106 | echo "*** Deleting a branch is not allowed in this repository" >&2 107 | exit 1 108 | fi 109 | ;; 110 | refs/remotes/*,commit) 111 | # tracking branch 112 | ;; 113 | refs/remotes/*,delete) 114 | # delete tracking branch 115 | if [ "$allowdeletebranch" != "true" ]; then 116 | echo "*** Deleting a tracking branch is not allowed in this repository" >&2 117 | exit 1 118 | fi 119 | ;; 120 | *) 121 | # Anything else (is there anything else?) 122 | echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 123 | exit 1 124 | ;; 125 | esac 126 | 127 | # --- Finished 128 | exit 0 129 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/index: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-git/DOTgit/index -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/info/exclude: -------------------------------------------------------------------------------- 1 | # git ls-files --others --exclude-from=.git/info/exclude 2 | # Lines that start with '#' are comments. 3 | # For a project mostly in C, the following would be a good set of 4 | # exclude patterns (uncomment them if you want to use them): 5 | # *.[oa] 6 | # *~ 7 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/logs/HEAD: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000 7525d17cbbb56f3253a20903ffddc07c6c935c76 Mitchell Hashimoto 1414446684 -0700 commit (initial): Those files tho 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/logs/refs/heads/master: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000 7525d17cbbb56f3253a20903ffddc07c6c935c76 Mitchell Hashimoto 1414446684 -0700 commit (initial): Those files tho 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/objects/25/7cc5642cb1a054f08cc83f2d943e56fd3ebe99: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-git/DOTgit/objects/25/7cc5642cb1a054f08cc83f2d943e56fd3ebe99 -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/objects/57/16ca5987cbf97d6bb54920bea6adde242d87e6: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-git/DOTgit/objects/57/16ca5987cbf97d6bb54920bea6adde242d87e6 -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/objects/75/25d17cbbb56f3253a20903ffddc07c6c935c76: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-git/DOTgit/objects/75/25d17cbbb56f3253a20903ffddc07c6c935c76 -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/objects/7e/49ea5550b356e32b63c044201f5f7da1e0925f: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-git/DOTgit/objects/7e/49ea5550b356e32b63c044201f5f7da1e0925f -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/objects/7f/7402c7d2a6e71ca3db3e236099771b160b8ad1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-git/DOTgit/objects/7f/7402c7d2a6e71ca3db3e236099771b160b8ad1 -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/DOTgit/refs/heads/master: -------------------------------------------------------------------------------- 1 | 7525d17cbbb56f3253a20903ffddc07c6c935c76 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/bar.txt: -------------------------------------------------------------------------------- 1 | bar 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/foo.txt: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/subdir/hello.txt: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-git/untracked.txt: -------------------------------------------------------------------------------- 1 | nope 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/00changelog.i: -------------------------------------------------------------------------------- 1 |  dummy changelog to prevent using the old repo layout -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/cache/branch2-served: -------------------------------------------------------------------------------- 1 | 2e4c00191f239e489dca961dbd6fca8fe0d93e2e 0 2 | 2e4c00191f239e489dca961dbd6fca8fe0d93e2e o default 3 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/dirstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-hg/.hg/dirstate -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/last-message.txt: -------------------------------------------------------------------------------- 1 | Tubes 2 | 3 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/requires: -------------------------------------------------------------------------------- 1 | dotencode 2 | fncache 3 | revlogv1 4 | store 5 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/store/00changelog.i: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-hg/.hg/store/00changelog.i -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/store/00manifest.i: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-hg/.hg/store/00manifest.i -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/store/data/bar.txt.i: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-hg/.hg/store/data/bar.txt.i -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/store/data/foo.txt.i: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-hg/.hg/store/data/foo.txt.i -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/store/data/subdir/hello.txt.i: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-hg/.hg/store/data/subdir/hello.txt.i -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/store/fncache: -------------------------------------------------------------------------------- 1 | data/bar.txt.i 2 | data/foo.txt.i 3 | data/subdir/hello.txt.i 4 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/store/phaseroots: -------------------------------------------------------------------------------- 1 | 1 2e4c00191f239e489dca961dbd6fca8fe0d93e2e 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/store/undo: -------------------------------------------------------------------------------- 1 | data/bar.txt.i0 2 | data/foo.txt.i0 3 | data/subdir/hello.txt.i0 4 | 00manifest.i0 5 | 00changelog.i0 6 | fncache0 7 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/store/undo.phaseroots: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-hg/.hg/store/undo.phaseroots -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/undo.bookmarks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-hg/.hg/undo.bookmarks -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/undo.branch: -------------------------------------------------------------------------------- 1 | default -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/undo.desc: -------------------------------------------------------------------------------- 1 | 0 2 | commit 3 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/.hg/undo.dirstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hashicorp/atlas-go/46e9b3e5e4f9f53d31ccddeebfed9acf05641861/archive/test-fixtures/archive-hg/.hg/undo.dirstate -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/bar.txt: -------------------------------------------------------------------------------- 1 | bar 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/foo.txt: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-hg/subdir/hello.txt: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-subdir-splat/bar.txt: -------------------------------------------------------------------------------- 1 | bar 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-subdir-splat/build/darwin-amd64/build.txt: -------------------------------------------------------------------------------- 1 | build.txt 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-subdir-splat/build/linux-amd64/build.txt: -------------------------------------------------------------------------------- 1 | linux-amd64 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-subdir/bar.txt: -------------------------------------------------------------------------------- 1 | bar 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-subdir/foo.txt: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-subdir/subdir/hello.txt: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /archive/test-fixtures/archive-symlink-file/link/deeper/adeeperlink: -------------------------------------------------------------------------------- 1 | ../../real/foo.txt -------------------------------------------------------------------------------- /archive/test-fixtures/archive-symlink-file/link/deeper/linklink: -------------------------------------------------------------------------------- 1 | adeeperlink -------------------------------------------------------------------------------- /archive/test-fixtures/archive-symlink-file/link/deeper/linklinklink: -------------------------------------------------------------------------------- 1 | linklink -------------------------------------------------------------------------------- /archive/test-fixtures/archive-symlink-file/link/link: -------------------------------------------------------------------------------- 1 | ../real/foo.txt -------------------------------------------------------------------------------- /archive/test-fixtures/archive-symlink-file/real/foo.txt: -------------------------------------------------------------------------------- 1 | tasty foo -------------------------------------------------------------------------------- /archive/test-fixtures/archive-symlink/link/link: -------------------------------------------------------------------------------- 1 | ../real -------------------------------------------------------------------------------- /archive/test-fixtures/archive-symlink/real/foo.txt: -------------------------------------------------------------------------------- 1 | tasty foo -------------------------------------------------------------------------------- /archive/vcs.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strings" 12 | 13 | version "github.com/hashicorp/go-version" 14 | ) 15 | 16 | // VCS is a struct that explains how to get the file list for a given 17 | // VCS. 18 | type VCS struct { 19 | Name string 20 | 21 | // Detect is a list of files/folders that if they exist, signal that 22 | // this VCS is the VCS in use. 23 | Detect []string 24 | 25 | // Files returns the files that are under version control for the 26 | // given path. 27 | Files VCSFilesFunc 28 | 29 | // Metadata returns arbitrary metadata about the underlying VCS for the 30 | // given path. 31 | Metadata VCSMetadataFunc 32 | 33 | // Preflight is a function to run before looking for VCS files. 34 | Preflight VCSPreflightFunc 35 | } 36 | 37 | // VCSList is the list of VCS we recognize. 38 | var VCSList = []*VCS{ 39 | &VCS{ 40 | Name: "git", 41 | Detect: []string{".git/"}, 42 | Preflight: gitPreflight, 43 | Files: vcsFilesCmd("git", "ls-files"), 44 | Metadata: gitMetadata, 45 | }, 46 | &VCS{ 47 | Name: "hg", 48 | Detect: []string{".hg/"}, 49 | Files: vcsTrimCmd(vcsFilesCmd("hg", "locate", "-f", "--include", ".")), 50 | }, 51 | &VCS{ 52 | Name: "svn", 53 | Detect: []string{".svn/"}, 54 | Files: vcsFilesCmd("svn", "ls"), 55 | }, 56 | } 57 | 58 | // VCSFilesFunc is the callback invoked to return the files in the VCS. 59 | // 60 | // The return value should be paths relative to the given path. 61 | type VCSFilesFunc func(string) ([]string, error) 62 | 63 | // VCSMetadataFunc is the callback invoked to get arbitrary information about 64 | // the current VCS. 65 | // 66 | // The return value should be a map of key-value pairs. 67 | type VCSMetadataFunc func(string) (map[string]string, error) 68 | 69 | // VCSPreflightFunc is a function that runs before VCS detection to be 70 | // configured by the user. It may be used to check if pre-requisites (like the 71 | // actual VCS) are installed or that a program is at the correct version. If an 72 | // error is returned, the VCS will not be processed and the error will be 73 | // returned up the stack. 74 | // 75 | // The given argument is the path where the VCS is running. 76 | type VCSPreflightFunc func(string) error 77 | 78 | // vcsDetect detects the VCS that is used for path. 79 | func vcsDetect(path string) (*VCS, error) { 80 | dir := path 81 | for { 82 | for _, v := range VCSList { 83 | for _, f := range v.Detect { 84 | check := filepath.Join(dir, f) 85 | if _, err := os.Stat(check); err == nil { 86 | return v, nil 87 | } 88 | } 89 | } 90 | lastDir := dir 91 | dir = filepath.Dir(dir) 92 | if dir == lastDir { 93 | break 94 | } 95 | } 96 | 97 | return nil, fmt.Errorf("no VCS found for path: %s", path) 98 | } 99 | 100 | // vcsPreflight returns the metadata for the VCS directory path. 101 | func vcsPreflight(path string) error { 102 | vcs, err := vcsDetect(path) 103 | if err != nil { 104 | return fmt.Errorf("error detecting VCS: %s", err) 105 | } 106 | 107 | if vcs.Preflight != nil { 108 | return vcs.Preflight(path) 109 | } 110 | 111 | return nil 112 | } 113 | 114 | // vcsFiles returns the files for the VCS directory path. 115 | func vcsFiles(path string) ([]string, error) { 116 | vcs, err := vcsDetect(path) 117 | if err != nil { 118 | return nil, fmt.Errorf("error detecting VCS: %s", err) 119 | } 120 | 121 | if vcs.Files != nil { 122 | return vcs.Files(path) 123 | } 124 | 125 | return nil, nil 126 | } 127 | 128 | // vcsFilesCmd creates a Files-compatible function that reads the files 129 | // by executing the command in the repository path and returning each 130 | // line in stdout. 131 | func vcsFilesCmd(args ...string) VCSFilesFunc { 132 | return func(path string) ([]string, error) { 133 | var stderr, stdout bytes.Buffer 134 | 135 | cmd := exec.Command(args[0], args[1:]...) 136 | cmd.Dir = path 137 | cmd.Stdout = &stdout 138 | cmd.Stderr = &stderr 139 | if err := cmd.Run(); err != nil { 140 | return nil, fmt.Errorf( 141 | "error executing %s: %s", 142 | strings.Join(args, " "), 143 | err) 144 | } 145 | 146 | // Read each line of output as a path 147 | result := make([]string, 0, 100) 148 | scanner := bufio.NewScanner(&stdout) 149 | for scanner.Scan() { 150 | result = append(result, scanner.Text()) 151 | } 152 | 153 | // Always use *nix-style paths (for Windows) 154 | for idx, value := range result { 155 | result[idx] = filepath.ToSlash(value) 156 | } 157 | 158 | return result, nil 159 | } 160 | } 161 | 162 | // vcsTrimCmd trims the prefix from the paths returned by another VCSFilesFunc. 163 | // This should be used to wrap another function if the return value is known 164 | // to have full paths rather than relative paths 165 | func vcsTrimCmd(f VCSFilesFunc) VCSFilesFunc { 166 | return func(path string) ([]string, error) { 167 | absPath, err := filepath.Abs(path) 168 | if err != nil { 169 | return nil, fmt.Errorf( 170 | "error expanding VCS path: %s", err) 171 | } 172 | 173 | // Now that we have the root path, get the inner files 174 | fs, err := f(path) 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | // Trim the root path from the files 180 | result := make([]string, 0, len(fs)) 181 | for _, f := range fs { 182 | if !strings.HasPrefix(f, absPath) { 183 | continue 184 | } 185 | 186 | f, err = filepath.Rel(absPath, f) 187 | if err != nil { 188 | return nil, fmt.Errorf( 189 | "error determining path: %s", err) 190 | } 191 | 192 | result = append(result, f) 193 | } 194 | 195 | return result, nil 196 | } 197 | } 198 | 199 | // vcsMetadata returns the metadata for the VCS directory path. 200 | func vcsMetadata(path string) (map[string]string, error) { 201 | vcs, err := vcsDetect(path) 202 | if err != nil { 203 | return nil, fmt.Errorf("error detecting VCS: %s", err) 204 | } 205 | 206 | if vcs.Metadata != nil { 207 | return vcs.Metadata(path) 208 | } 209 | 210 | return nil, nil 211 | } 212 | 213 | const ignorableDetachedHeadError = "HEAD is not a symbolic ref" 214 | 215 | // gitBranch gets and returns the current git branch for the Git repository 216 | // at the given path. It is assumed that the VCS is git. 217 | func gitBranch(path string) (string, error) { 218 | var stderr, stdout bytes.Buffer 219 | 220 | cmd := exec.Command("git", "symbolic-ref", "--short", "HEAD") 221 | cmd.Dir = path 222 | cmd.Stdout = &stdout 223 | cmd.Stderr = &stderr 224 | if err := cmd.Run(); err != nil { 225 | if strings.Contains(stderr.String(), ignorableDetachedHeadError) { 226 | return "", nil 227 | } else { 228 | return "", 229 | fmt.Errorf("error getting git branch: %s\nstdout: %s\nstderr: %s", 230 | err, stdout.String(), stderr.String()) 231 | } 232 | } 233 | 234 | branch := strings.TrimSpace(stdout.String()) 235 | 236 | return branch, nil 237 | } 238 | 239 | // gitCommit gets the SHA of the latest commit for the Git repository at the 240 | // given path. It is assumed that the VCS is git. 241 | func gitCommit(path string) (string, error) { 242 | var stderr, stdout bytes.Buffer 243 | 244 | cmd := exec.Command("git", "log", "-n1", "--pretty=format:%H") 245 | cmd.Dir = path 246 | cmd.Stdout = &stdout 247 | cmd.Stderr = &stderr 248 | if err := cmd.Run(); err != nil { 249 | return "", fmt.Errorf("error getting git commit: %s\nstdout: %s\nstderr: %s", 250 | err, stdout.String(), stderr.String()) 251 | } 252 | 253 | commit := strings.TrimSpace(stdout.String()) 254 | 255 | return commit, nil 256 | } 257 | 258 | // gitRemotes gets and returns a map of all remotes for the Git repository. The 259 | // map key is the name of the remote of the format "remote.NAME" and the value 260 | // is the endpoint for the remote. It is assumed that the VCS is git. 261 | func gitRemotes(path string) (map[string]string, error) { 262 | var stderr, stdout bytes.Buffer 263 | 264 | cmd := exec.Command("git", "remote", "-v") 265 | cmd.Dir = path 266 | cmd.Stdout = &stdout 267 | cmd.Stderr = &stderr 268 | if err := cmd.Run(); err != nil { 269 | return nil, fmt.Errorf("error getting git remotes: %s\nstdout: %s\nstderr: %s", 270 | err, stdout.String(), stderr.String()) 271 | } 272 | 273 | // Read each line of output as a remote 274 | result := make(map[string]string) 275 | scanner := bufio.NewScanner(&stdout) 276 | for scanner.Scan() { 277 | line := scanner.Text() 278 | split := strings.Split(line, "\t") 279 | 280 | if len(split) < 2 { 281 | return nil, fmt.Errorf("invalid response from git remote: %s", stdout.String()) 282 | } 283 | 284 | remote := fmt.Sprintf("remote.%s", strings.TrimSpace(split[0])) 285 | if _, ok := result[remote]; !ok { 286 | // https://github.com/foo/bar.git (fetch) #=> https://github.com/foo/bar.git 287 | urlSplit := strings.Split(split[1], " ") 288 | result[remote] = strings.TrimSpace(urlSplit[0]) 289 | } 290 | } 291 | 292 | return result, nil 293 | } 294 | 295 | // gitPreflight is the pre-flight command that runs for Git-based VCSs 296 | func gitPreflight(path string) error { 297 | var stderr, stdout bytes.Buffer 298 | 299 | cmd := exec.Command("git", "--version") 300 | cmd.Dir = path 301 | cmd.Stdout = &stdout 302 | cmd.Stderr = &stderr 303 | if err := cmd.Run(); err != nil { 304 | return fmt.Errorf("error getting git version: %s\nstdout: %s\nstderr: %s", 305 | err, stdout.String(), stderr.String()) 306 | } 307 | 308 | // Check if the output is valid 309 | output := strings.Split(strings.TrimSpace(stdout.String()), " ") 310 | if len(output) < 1 { 311 | log.Printf("[WARN] could not extract version output from Git") 312 | return nil 313 | } 314 | 315 | // Parse the version 316 | gitv, err := version.NewVersion(output[len(output)-1]) 317 | if err != nil { 318 | log.Printf("[WARN] could not parse version output from Git") 319 | return nil 320 | } 321 | 322 | constraint, err := version.NewConstraint("> 1.8") 323 | if err != nil { 324 | log.Printf("[WARN] could not create version constraint to check") 325 | return nil 326 | } 327 | if !constraint.Check(gitv) { 328 | return fmt.Errorf("git version (%s) is too old, please upgrade", gitv.String()) 329 | } 330 | 331 | return nil 332 | } 333 | 334 | // gitMetadata is the function to parse and return Git metadata 335 | func gitMetadata(path string) (map[string]string, error) { 336 | // Future-self note: Git is NOT threadsafe, so we cannot run these 337 | // operations in go routines or else you're going to have a really really 338 | // bad day and Panda.State == "Sad" :( 339 | 340 | branch, err := gitBranch(path) 341 | if err != nil { 342 | return nil, err 343 | } 344 | 345 | commit, err := gitCommit(path) 346 | if err != nil { 347 | return nil, err 348 | } 349 | 350 | remotes, err := gitRemotes(path) 351 | if err != nil { 352 | return nil, err 353 | } 354 | 355 | // Make the return result (we already know the size) 356 | result := make(map[string]string, 2+len(remotes)) 357 | 358 | result["branch"] = branch 359 | result["commit"] = commit 360 | for remote, value := range remotes { 361 | result[remote] = value 362 | } 363 | 364 | return result, nil 365 | } 366 | -------------------------------------------------------------------------------- /archive/vcs_test.go: -------------------------------------------------------------------------------- 1 | package archive 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "reflect" 9 | "runtime" 10 | "testing" 11 | ) 12 | 13 | func setupGitFixtures(t *testing.T) (string, func()) { 14 | testDir := testFixture("archive-git") 15 | oldName := filepath.Join(testDir, "DOTgit") 16 | newName := filepath.Join(testDir, ".git") 17 | 18 | cleanup := func() { 19 | os.Rename(newName, oldName) 20 | // Windows leaves an empty folder lying around afterward 21 | if runtime.GOOS == "windows" { 22 | os.Remove(newName) 23 | } 24 | } 25 | 26 | // We call this BEFORE and after each setup for tests that make lower-level 27 | // calls like runCommand 28 | cleanup() 29 | 30 | if err := os.Rename(oldName, newName); err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | return testDir, cleanup 35 | } 36 | 37 | func TestVCSPreflight(t *testing.T) { 38 | if !testHasGit { 39 | t.Skip("git not found") 40 | } 41 | 42 | testDir, cleanup := setupGitFixtures(t) 43 | defer cleanup() 44 | 45 | if err := vcsPreflight(testDir); err != nil { 46 | t.Fatal(err) 47 | } 48 | } 49 | 50 | func TestGitBranch(t *testing.T) { 51 | if !testHasGit { 52 | t.Skip("git not found") 53 | } 54 | 55 | testDir, cleanup := setupGitFixtures(t) 56 | defer cleanup() 57 | 58 | branch, err := gitBranch(testDir) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | expected := "master" 64 | if branch != expected { 65 | t.Fatalf("expected %q to be %q", branch, expected) 66 | } 67 | } 68 | 69 | func TestGitBranch_detached(t *testing.T) { 70 | if !testHasGit { 71 | t.Skip("git not found") 72 | } 73 | 74 | testDir := testFixture("archive-git") 75 | oldName := filepath.Join(testDir, "DOTgit") 76 | newName := filepath.Join(testDir, ".git") 77 | pwd, err := os.Getwd() 78 | if err != nil { 79 | t.Fatalf("err: %#v", err) 80 | } 81 | 82 | // Copy and then remove the .git dir instead of moving and replacing like 83 | // other tests, since the checkout below is going to write to the reflog and 84 | // the index 85 | runCommand(t, pwd, "cp", "-r", oldName, newName) 86 | defer runCommand(t, pwd, "rm", "-rf", newName) 87 | 88 | runCommand(t, testDir, "git", "checkout", "--detach") 89 | 90 | branch, err := gitBranch(testDir) 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | 95 | if branch != "" { 96 | t.Fatalf("expected branch to be empty, but it was: %s", branch) 97 | } 98 | } 99 | 100 | func TestGitCommit(t *testing.T) { 101 | if !testHasGit { 102 | t.Skip("git not found") 103 | } 104 | 105 | testDir, cleanup := setupGitFixtures(t) 106 | defer cleanup() 107 | 108 | commit, err := gitCommit(testDir) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | 113 | expected := "7525d17cbbb56f3253a20903ffddc07c6c935c76" 114 | if commit != expected { 115 | t.Fatalf("expected %q to be %q", commit, expected) 116 | } 117 | } 118 | 119 | func TestGitRemotes(t *testing.T) { 120 | if !testHasGit { 121 | t.Skip("git not found") 122 | } 123 | 124 | testDir, cleanup := setupGitFixtures(t) 125 | defer cleanup() 126 | 127 | remotes, err := gitRemotes(testDir) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | 132 | expected := map[string]string{ 133 | "remote.origin": "https://github.com/hashicorp/origin.git", 134 | "remote.upstream": "https://github.com/hashicorp/upstream.git", 135 | } 136 | 137 | if !reflect.DeepEqual(remotes, expected) { 138 | t.Fatalf("expected %+v to be %+v", remotes, expected) 139 | } 140 | } 141 | 142 | func TestVCSMetadata_git(t *testing.T) { 143 | if !testHasGit { 144 | t.Skip("git not found") 145 | } 146 | 147 | testDir, cleanup := setupGitFixtures(t) 148 | defer cleanup() 149 | 150 | metadata, err := vcsMetadata(testDir) 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | 155 | expected := map[string]string{ 156 | "branch": "master", 157 | "commit": "7525d17cbbb56f3253a20903ffddc07c6c935c76", 158 | "remote.origin": "https://github.com/hashicorp/origin.git", 159 | "remote.upstream": "https://github.com/hashicorp/upstream.git", 160 | } 161 | 162 | if !reflect.DeepEqual(metadata, expected) { 163 | t.Fatalf("expected %+v to be %+v", metadata, expected) 164 | } 165 | } 166 | 167 | func TestVCSMetadata_git_detached(t *testing.T) { 168 | if !testHasGit { 169 | t.Skip("git not found") 170 | } 171 | 172 | testDir := testFixture("archive-git") 173 | oldName := filepath.Join(testDir, "DOTgit") 174 | newName := filepath.Join(testDir, ".git") 175 | pwd, err := os.Getwd() 176 | if err != nil { 177 | t.Fatalf("err: %#v", err) 178 | } 179 | 180 | // Copy and then remove the .git dir instead of moving and replacing like 181 | // other tests, since the checkout below is going to write to the reflog and 182 | // the index 183 | runCommand(t, pwd, "cp", "-r", oldName, newName) 184 | defer runCommand(t, pwd, "rm", "-rf", newName) 185 | 186 | runCommand(t, testDir, "git", "checkout", "--detach") 187 | 188 | metadata, err := vcsMetadata(testDir) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | 193 | expected := map[string]string{ 194 | "branch": "", 195 | "commit": "7525d17cbbb56f3253a20903ffddc07c6c935c76", 196 | "remote.origin": "https://github.com/hashicorp/origin.git", 197 | "remote.upstream": "https://github.com/hashicorp/upstream.git", 198 | } 199 | 200 | if !reflect.DeepEqual(metadata, expected) { 201 | t.Fatalf("expected %+v to be %+v", metadata, expected) 202 | } 203 | } 204 | 205 | func TestVCSPathDetect_git(t *testing.T) { 206 | testDir, cleanup := setupGitFixtures(t) 207 | defer cleanup() 208 | 209 | vcs, err := vcsDetect(testDir) 210 | if err != nil { 211 | t.Errorf("VCS detection failed") 212 | } 213 | 214 | if vcs.Name != "git" { 215 | t.Errorf("Expected to find git; found %s", vcs.Name) 216 | } 217 | } 218 | 219 | func TestVCSPathDetect_git_failure(t *testing.T) { 220 | _, err := vcsDetect(testFixture("archive-flat")) 221 | // We expect to get an error because there is no git repo here 222 | if err == nil { 223 | t.Errorf("VCS detection failed") 224 | } 225 | } 226 | 227 | func TestVCSPathDetect_hg(t *testing.T) { 228 | vcs, err := vcsDetect(testFixture("archive-hg")) 229 | if err != nil { 230 | t.Errorf("VCS detection failed") 231 | } 232 | 233 | if vcs.Name != "hg" { 234 | t.Errorf("Expected to find hg; found %s", vcs.Name) 235 | } 236 | } 237 | 238 | func TestVCSPathDetect_hg_absolute(t *testing.T) { 239 | abspath, err := filepath.Abs(testFixture("archive-hg")) 240 | vcs, err := vcsDetect(abspath) 241 | if err != nil { 242 | t.Errorf("VCS detection failed") 243 | } 244 | 245 | if vcs.Name != "hg" { 246 | t.Errorf("Expected to find hg; found %s", vcs.Name) 247 | } 248 | } 249 | 250 | func runCommand(t *testing.T, path, command string, args ...string) { 251 | var stderr, stdout bytes.Buffer 252 | cmd := exec.Command(command, args...) 253 | cmd.Dir = path 254 | cmd.Stdout = &stdout 255 | cmd.Stderr = &stderr 256 | if err := cmd.Run(); err != nil { 257 | t.Fatalf("error running command: %s\nstdout: %s\nstderr: %s", 258 | err, stdout.String(), stderr.String()) 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /v1/application.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | ) 10 | 11 | // appWrapper is the API wrapper since the server wraps the resulting object. 12 | type appWrapper struct { 13 | Application *App `json:"application"` 14 | } 15 | 16 | // App represents a single instance of an application on the Atlas server. 17 | type App struct { 18 | // User is the namespace (username or organization) under which the 19 | // Atlas application resides 20 | User string `json:"username"` 21 | 22 | // Name is the name of the application 23 | Name string `json:"name"` 24 | } 25 | 26 | // Slug returns the slug format for this App (User/Name) 27 | func (a *App) Slug() string { 28 | return fmt.Sprintf("%s/%s", a.User, a.Name) 29 | } 30 | 31 | // App gets the App by the given user space and name. In the event the App is 32 | // not found (404), or for any other non-200 responses, an error is returned. 33 | func (c *Client) App(user, name string) (*App, error) { 34 | log.Printf("[INFO] getting application %s/%s", user, name) 35 | 36 | endpoint := fmt.Sprintf("/api/v1/vagrant/applications/%s/%s", user, name) 37 | request, err := c.Request("GET", endpoint, nil) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | response, err := checkResp(c.HTTPClient.Do(request)) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | var app App 48 | if err := decodeJSON(response, &app); err != nil { 49 | return nil, err 50 | } 51 | 52 | return &app, nil 53 | } 54 | 55 | // CreateApp creates a new App under the given user with the given name. If the 56 | // App is created successfully, it is returned. If the server returns any 57 | // errors, an error is returned. 58 | func (c *Client) CreateApp(user, name string) (*App, error) { 59 | log.Printf("[INFO] creating application %s/%s", user, name) 60 | 61 | body, err := json.Marshal(&appWrapper{&App{ 62 | User: user, 63 | Name: name, 64 | }}) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | endpoint := "/api/v1/vagrant/applications" 70 | request, err := c.Request("POST", endpoint, &RequestOptions{ 71 | Body: bytes.NewReader(body), 72 | Headers: map[string]string{ 73 | "Content-Type": "application/json", 74 | }, 75 | }) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | response, err := checkResp(c.HTTPClient.Do(request)) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | var app App 86 | if err := decodeJSON(response, &app); err != nil { 87 | return nil, err 88 | } 89 | 90 | return &app, nil 91 | } 92 | 93 | // appVersion represents a specific version of an App in Atlas. It is actually 94 | // an upload container/wrapper. 95 | type appVersion struct { 96 | UploadPath string `json:"upload_path"` 97 | Token string `json:"token"` 98 | Version uint64 `json:"version"` 99 | } 100 | 101 | // appMetadataWrapper is a wrapper around a map the prefixes the json key with 102 | // "metadata" when marshalled to format requests to the API properly. 103 | type appMetadataWrapper struct { 104 | Metadata map[string]interface{} `json:"metadata,omitempty"` 105 | } 106 | 107 | // UploadApp creates and uploads a new version for the App. If the server does not 108 | // find the application, an error is returned. If the server does not accept the 109 | // data, an error is returned. 110 | // 111 | // It is the responsibility of the caller to create a properly-formed data 112 | // object; this method blindly passes along the contents of the io.Reader. 113 | func (c *Client) UploadApp(app *App, metadata map[string]interface{}, 114 | data io.Reader, size int64) (uint64, error) { 115 | 116 | log.Printf("[INFO] uploading application %s (%d bytes) with metadata %q", 117 | app.Slug(), size, metadata) 118 | 119 | endpoint := fmt.Sprintf("/api/v1/vagrant/applications/%s/%s/versions", 120 | app.User, app.Name) 121 | 122 | // If metadata was given, setup the RequestOptions to pass in the metadata 123 | // with the request. 124 | var ro *RequestOptions 125 | if metadata != nil { 126 | // wrap the struct into the correct JSON format 127 | wrapper := struct { 128 | Application *appMetadataWrapper `json:"application"` 129 | }{ 130 | &appMetadataWrapper{metadata}, 131 | } 132 | m, err := json.Marshal(wrapper) 133 | if err != nil { 134 | return 0, err 135 | } 136 | 137 | // Create the request options. 138 | ro = &RequestOptions{ 139 | Body: bytes.NewReader(m), 140 | BodyLength: int64(len(m)), 141 | } 142 | } 143 | 144 | request, err := c.Request("POST", endpoint, ro) 145 | if err != nil { 146 | return 0, err 147 | } 148 | 149 | response, err := checkResp(c.HTTPClient.Do(request)) 150 | if err != nil { 151 | return 0, err 152 | } 153 | 154 | var av appVersion 155 | if err := decodeJSON(response, &av); err != nil { 156 | return 0, err 157 | } 158 | 159 | if err := c.putFile(av.UploadPath, data, size); err != nil { 160 | return 0, err 161 | } 162 | 163 | return av.Version, nil 164 | } 165 | -------------------------------------------------------------------------------- /v1/application_test.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestSlug_returnsSlug(t *testing.T) { 9 | app := &App{ 10 | User: "hashicorp", 11 | Name: "project", 12 | } 13 | 14 | expected := "hashicorp/project" 15 | if app.Slug() != expected { 16 | t.Fatalf("expected %q to be %q", app.Slug(), expected) 17 | } 18 | } 19 | 20 | func TestApp_fetchesApp(t *testing.T) { 21 | server := newTestAtlasServer(t) 22 | defer server.Stop() 23 | 24 | client, err := NewClient(server.URL.String()) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | app, err := client.App("hashicorp", "existing") 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | if app.User != "hashicorp" { 35 | t.Errorf("expected %q to be %q", app.User, "hashicorp") 36 | } 37 | 38 | if app.Name != "existing" { 39 | t.Errorf("expected %q to be %q", app.Name, "existing") 40 | } 41 | } 42 | 43 | func TestApp_returnsErrorNoApp(t *testing.T) { 44 | server := newTestAtlasServer(t) 45 | defer server.Stop() 46 | 47 | client, err := NewClient(server.URL.String()) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | _, err = client.App("hashicorp", "newproject") 53 | if err == nil { 54 | t.Fatal("expected error, but nothing was returned") 55 | } 56 | } 57 | 58 | func TestCreateApp_createsAndReturnsApp(t *testing.T) { 59 | server := newTestAtlasServer(t) 60 | defer server.Stop() 61 | 62 | client, err := NewClient(server.URL.String()) 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | 67 | app, err := client.CreateApp("hashicorp", "newproject") 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | 72 | if app.User != "hashicorp" { 73 | t.Errorf("expected %q to be %q", app.User, "hashicorp") 74 | } 75 | 76 | if app.Name != "newproject" { 77 | t.Errorf("expected %q to be %q", app.Name, "newproject") 78 | } 79 | } 80 | 81 | func TestCreateApp_returnsErrorExistingApp(t *testing.T) { 82 | server := newTestAtlasServer(t) 83 | defer server.Stop() 84 | 85 | client, err := NewClient(server.URL.String()) 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | 90 | _, err = client.CreateApp("hashicorp", "existing") 91 | if err == nil { 92 | t.Fatal("expected error, but nothing was returned") 93 | } 94 | } 95 | 96 | func TestUploadApp_createsAndReturnsVersion(t *testing.T) { 97 | server := newTestAtlasServer(t) 98 | defer server.Stop() 99 | 100 | client, err := NewClient(server.URL.String()) 101 | if err != nil { 102 | t.Fatal(err) 103 | } 104 | 105 | app := &App{ 106 | User: "hashicorp", 107 | Name: "existing", 108 | } 109 | metadata := map[string]interface{}{"testing": true} 110 | data := new(bytes.Buffer) 111 | version, err := client.UploadApp(app, metadata, data, int64(data.Len())) 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | if version != 125 { 116 | t.Fatalf("bad: %#v", version) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /v1/artifact.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/url" 10 | ) 11 | 12 | // Artifact represents a single instance of an artifact. 13 | type Artifact struct { 14 | // User and name are self-explanatory. Tag is the combination 15 | // of both into "username/name" 16 | User string `json:"username"` 17 | Name string `json:"name"` 18 | Tag string `json:",omitempty"` 19 | } 20 | 21 | // ArtifactVersion represents a single version of an artifact. 22 | type ArtifactVersion struct { 23 | User string `json:"username"` 24 | Name string `json:"name"` 25 | Tag string `json:",omitempty"` 26 | Type string `json:"artifact_type"` 27 | ID string `json:"id"` 28 | Version int `json:"version"` 29 | Metadata map[string]string `json:"metadata"` 30 | File bool `json:"file"` 31 | Slug string `json:"slug"` 32 | 33 | UploadPath string `json:"upload_path"` 34 | UploadToken string `json:"upload_token"` 35 | } 36 | 37 | // ArtifactSearchOpts are the options used to search for an artifact. 38 | type ArtifactSearchOpts struct { 39 | User string 40 | Name string 41 | Type string 42 | 43 | Build string 44 | Version string 45 | Metadata map[string]string 46 | } 47 | 48 | // UploadArtifactOpts are the options used to upload an artifact. 49 | type UploadArtifactOpts struct { 50 | User string 51 | Name string 52 | Type string 53 | ID string 54 | File io.Reader 55 | FileSize int64 56 | Metadata map[string]string 57 | BuildID int 58 | CompileID int 59 | } 60 | 61 | // MarshalJSON converts the UploadArtifactOpts into a JSON struct. 62 | func (o *UploadArtifactOpts) MarshalJSON() ([]byte, error) { 63 | return json.Marshal(map[string]interface{}{ 64 | "artifact_version": map[string]interface{}{ 65 | "id": o.ID, 66 | "file": o.File != nil, 67 | "metadata": o.Metadata, 68 | "build_id": o.BuildID, 69 | "compile_id": o.CompileID, 70 | }, 71 | }) 72 | } 73 | 74 | // This is the value that should be used for metadata in ArtifactSearchOpts 75 | // if you don't care what the value is. 76 | const MetadataAnyValue = "943febbf-589f-401b-8f25-58f6d8786848" 77 | 78 | // Artifact finds the Atlas artifact by the given name and returns it. Any 79 | // errors that occur are returned, including ErrAuth and ErrNotFound special 80 | // exceptions which the user may want to handle separately. 81 | func (c *Client) Artifact(user, name string) (*Artifact, error) { 82 | endpoint := fmt.Sprintf("/api/v1/artifacts/%s/%s", user, name) 83 | request, err := c.Request("GET", endpoint, nil) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | response, err := checkResp(c.HTTPClient.Do(request)) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | var aw artifactWrapper 94 | if err := decodeJSON(response, &aw); err != nil { 95 | return nil, err 96 | } 97 | 98 | return aw.Artifact, nil 99 | } 100 | 101 | // ArtifactSearch searches Atlas for the given ArtifactSearchOpts and returns 102 | // a slice of ArtifactVersions. 103 | func (c *Client) ArtifactSearch(opts *ArtifactSearchOpts) ([]*ArtifactVersion, error) { 104 | log.Printf("[INFO] searching artifacts: %#v", opts) 105 | 106 | params := make(map[string]string) 107 | if opts.Version != "" { 108 | params["version"] = opts.Version 109 | } 110 | if opts.Build != "" { 111 | params["build"] = opts.Build 112 | } 113 | 114 | i := 1 115 | for k, v := range opts.Metadata { 116 | prefix := fmt.Sprintf("metadata.%d.", i) 117 | params[prefix+"key"] = k 118 | if v != MetadataAnyValue { 119 | params[prefix+"value"] = v 120 | } 121 | 122 | i++ 123 | } 124 | 125 | endpoint := fmt.Sprintf("/api/v1/artifacts/%s/%s/%s/search", 126 | opts.User, opts.Name, opts.Type) 127 | request, err := c.Request("GET", endpoint, &RequestOptions{ 128 | Params: params, 129 | }) 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | response, err := checkResp(c.HTTPClient.Do(request)) 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | var w artifactSearchWrapper 140 | if err := decodeJSON(response, &w); err != nil { 141 | return nil, err 142 | } 143 | 144 | return w.Versions, nil 145 | } 146 | 147 | // CreateArtifact creates and returns a new Artifact in Atlas. Any errors that 148 | // occurr are returned. 149 | func (c *Client) CreateArtifact(user, name string) (*Artifact, error) { 150 | log.Printf("[INFO] creating artifact: %s/%s", user, name) 151 | body, err := json.Marshal(&artifactWrapper{&Artifact{ 152 | User: user, 153 | Name: name, 154 | }}) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | endpoint := "/api/v1/artifacts" 160 | request, err := c.Request("POST", endpoint, &RequestOptions{ 161 | Body: bytes.NewReader(body), 162 | Headers: map[string]string{ 163 | "Content-Type": "application/json", 164 | }, 165 | }) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | response, err := checkResp(c.HTTPClient.Do(request)) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | var aw artifactWrapper 176 | if err := decodeJSON(response, &aw); err != nil { 177 | return nil, err 178 | } 179 | 180 | return aw.Artifact, nil 181 | } 182 | 183 | // ArtifactFileURL is a helper method for getting the URL for an ArtifactVersion 184 | // from the Client. 185 | func (c *Client) ArtifactFileURL(av *ArtifactVersion) (*url.URL, error) { 186 | if !av.File { 187 | return nil, nil 188 | } 189 | 190 | u := *c.URL 191 | u.Path = fmt.Sprintf("/api/v1/artifacts/%s/%s/%s/%d/file", 192 | av.User, av.Name, av.Type, av.Version) 193 | return &u, nil 194 | } 195 | 196 | // UploadArtifact streams the upload of a file on disk using the given 197 | // UploadArtifactOpts. Any errors that occur are returned. 198 | func (c *Client) UploadArtifact(opts *UploadArtifactOpts) (*ArtifactVersion, error) { 199 | log.Printf("[INFO] uploading artifact: %s/%s (%s)", opts.User, opts.Name, opts.Type) 200 | 201 | endpoint := fmt.Sprintf("/api/v1/artifacts/%s/%s/%s", 202 | opts.User, opts.Name, opts.Type) 203 | 204 | body, err := json.Marshal(opts) 205 | if err != nil { 206 | return nil, err 207 | } 208 | 209 | request, err := c.Request("POST", endpoint, &RequestOptions{ 210 | Body: bytes.NewReader(body), 211 | Headers: map[string]string{ 212 | "Content-Type": "application/json", 213 | }, 214 | }) 215 | if err != nil { 216 | return nil, err 217 | } 218 | 219 | response, err := checkResp(c.HTTPClient.Do(request)) 220 | if err != nil { 221 | return nil, err 222 | } 223 | 224 | var av ArtifactVersion 225 | if err := decodeJSON(response, &av); err != nil { 226 | return nil, err 227 | } 228 | 229 | if opts.File != nil { 230 | if err := c.putFile(av.UploadPath, opts.File, opts.FileSize); err != nil { 231 | return nil, err 232 | } 233 | } 234 | 235 | return &av, nil 236 | } 237 | 238 | type artifactWrapper struct { 239 | Artifact *Artifact `json:"artifact"` 240 | } 241 | 242 | type artifactSearchWrapper struct { 243 | Versions []*ArtifactVersion 244 | } 245 | 246 | type artifactVersionWrapper struct { 247 | Version *ArtifactVersion 248 | } 249 | -------------------------------------------------------------------------------- /v1/artifact_test.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestArtifact_fetchesArtifact(t *testing.T) { 9 | server := newTestAtlasServer(t) 10 | defer server.Stop() 11 | 12 | client, err := NewClient(server.URL.String()) 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | art, err := client.Artifact("hashicorp", "existing") 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | if art.User != "hashicorp" { 23 | t.Errorf("expected %q to be %q", art.User, "hashicorp") 24 | } 25 | 26 | if art.Name != "existing" { 27 | t.Errorf("expected %q to be %q", art.Name, "existing") 28 | } 29 | } 30 | 31 | func TestArtifact_returnsErrorNoArtifact(t *testing.T) { 32 | server := newTestAtlasServer(t) 33 | defer server.Stop() 34 | 35 | client, err := NewClient(server.URL.String()) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | _, err = client.App("hashicorp", "newproject") 41 | if err == nil { 42 | t.Fatal("expected error, but nothing was returned") 43 | } 44 | } 45 | 46 | func TestArtifactSearch_fetches(t *testing.T) { 47 | server := newTestAtlasServer(t) 48 | defer server.Stop() 49 | 50 | client, err := NewClient(server.URL.String()) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | vs, err := client.ArtifactSearch(&ArtifactSearchOpts{ 56 | User: "hashicorp", 57 | Name: "existing1", 58 | Type: "amazon-ami", 59 | }) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | if len(vs) != 1 { 65 | t.Fatalf("bad: %#v", vs) 66 | } 67 | } 68 | 69 | func TestArtifactSearch_metadata(t *testing.T) { 70 | server := newTestAtlasServer(t) 71 | defer server.Stop() 72 | 73 | client, err := NewClient(server.URL.String()) 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | 78 | vs, err := client.ArtifactSearch(&ArtifactSearchOpts{ 79 | User: "hashicorp", 80 | Name: "existing2", 81 | Type: "amazon-ami", 82 | Metadata: map[string]string{ 83 | "foo": "bar", 84 | "bar": MetadataAnyValue, 85 | }, 86 | }) 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | 91 | if len(vs) != 1 { 92 | t.Fatalf("bad: %#v", vs) 93 | } 94 | } 95 | 96 | func TestArtifactFileURL(t *testing.T) { 97 | server := newTestAtlasServer(t) 98 | defer server.Stop() 99 | 100 | client, err := NewClient(server.URL.String()) 101 | if err != nil { 102 | t.Fatal(err) 103 | } 104 | 105 | v := &ArtifactVersion{ 106 | User: "foo", 107 | Name: "bar", 108 | Version: 1, 109 | Type: "vagrant-box", 110 | File: true, 111 | } 112 | 113 | u, err := client.ArtifactFileURL(v) 114 | if err != nil { 115 | t.Fatal(err) 116 | } 117 | 118 | expected := *server.URL 119 | expected.Path = "/api/v1/artifacts/foo/bar/vagrant-box/1/file" 120 | if u.String() != expected.String() { 121 | t.Fatalf("unexpected: %s\n\nexpected: %s", u, expected.String()) 122 | } 123 | } 124 | 125 | func TestArtifactFileURL_nil(t *testing.T) { 126 | server := newTestAtlasServer(t) 127 | defer server.Stop() 128 | 129 | client, err := NewClient(server.URL.String()) 130 | if err != nil { 131 | t.Fatal(err) 132 | } 133 | 134 | v := &ArtifactVersion{ 135 | User: "foo", 136 | Name: "bar", 137 | Type: "vagrant-box", 138 | } 139 | 140 | u, err := client.ArtifactFileURL(v) 141 | if err != nil { 142 | t.Fatal(err) 143 | } 144 | if u != nil { 145 | t.Fatal("should be nil") 146 | } 147 | } 148 | 149 | func TestUploadArtifact(t *testing.T) { 150 | server := newTestAtlasServer(t) 151 | defer server.Stop() 152 | 153 | client, err := NewClient(server.URL.String()) 154 | if err != nil { 155 | t.Fatal(err) 156 | } 157 | 158 | data := new(bytes.Buffer) 159 | _, err = client.UploadArtifact(&UploadArtifactOpts{ 160 | User: "hashicorp", 161 | Name: "existing", 162 | Type: "amazon-ami", 163 | File: data, 164 | }) 165 | if err != nil { 166 | t.Fatal(err) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /v1/atlas_test.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net" 10 | "net/http" 11 | "net/url" 12 | "path" 13 | "reflect" 14 | "strconv" 15 | "strings" 16 | "testing" 17 | ) 18 | 19 | type atlasServer struct { 20 | URL *url.URL 21 | 22 | t *testing.T 23 | ln net.Listener 24 | server *http.Server 25 | } 26 | 27 | type clientTestResp struct { 28 | RawPath string 29 | Host string 30 | Header http.Header 31 | Body string 32 | } 33 | 34 | func newTestAtlasServer(t *testing.T) *atlasServer { 35 | hs := &atlasServer{t: t} 36 | 37 | ln, err := net.Listen("tcp", ":0") 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | hs.ln = ln 42 | 43 | hs.URL = &url.URL{ 44 | Scheme: "http", 45 | Host: ln.Addr().String(), 46 | } 47 | 48 | mux := http.NewServeMux() 49 | hs.setupRoutes(mux) 50 | 51 | // TODO: this should be using httptest.Server 52 | server := &http.Server{} 53 | server.Handler = mux 54 | hs.server = server 55 | go server.Serve(ln) 56 | 57 | return hs 58 | } 59 | 60 | func (hs *atlasServer) Stop() { 61 | hs.ln.Close() 62 | } 63 | 64 | func (hs *atlasServer) setupRoutes(mux *http.ServeMux) { 65 | mux.HandleFunc("/_json", hs.jsonHandler) 66 | mux.HandleFunc("/_rails-error", hs.railsHandler) 67 | mux.HandleFunc("/_status/", hs.statusHandler) 68 | 69 | mux.HandleFunc("/_binstore/", hs.binstoreHandler) 70 | 71 | mux.HandleFunc("/api/v1/authenticate", hs.authenticationHandler) 72 | mux.HandleFunc("/api/v1/token", hs.tokenHandler) 73 | 74 | mux.HandleFunc("/api/v1/artifacts/hashicorp/existing", hs.vagrantArtifactExistingHandler) 75 | mux.HandleFunc("/api/v1/artifacts/hashicorp/existing/amazon-ami", hs.vagrantArtifactUploadHandler) 76 | mux.HandleFunc("/api/v1/artifacts/hashicorp/existing1/amazon-ami/search", hs.vagrantArtifactSearchHandler1) 77 | mux.HandleFunc("/api/v1/artifacts/hashicorp/existing2/amazon-ami/search", hs.vagrantArtifactSearchHandler2) 78 | 79 | mux.HandleFunc("/api/v1/vagrant/applications", hs.vagrantCreateAppHandler) 80 | mux.HandleFunc("/api/v1/vagrant/applications/", hs.vagrantCreateAppsHandler) 81 | mux.HandleFunc("/api/v1/vagrant/applications/hashicorp/existing", hs.vagrantAppExistingHandler) 82 | mux.HandleFunc("/api/v1/vagrant/applications/hashicorp/existing/versions", hs.vagrantUploadAppHandler) 83 | 84 | mux.HandleFunc("/api/v1/packer/build-configurations", hs.vagrantBCCreateHandler) 85 | mux.HandleFunc("/api/v1/packer/build-configurations/hashicorp/existing", hs.vagrantBCExistingHandler) 86 | mux.HandleFunc("/api/v1/packer/build-configurations/hashicorp/existing/versions", hs.vagrantBCCreateVersionHandler) 87 | 88 | mux.HandleFunc("/api/v1/terraform/configurations/hashicorp/existing/versions/latest", hs.tfConfigLatest) 89 | mux.HandleFunc("/api/v1/terraform/configurations/hashicorp/existing/versions", hs.tfConfigUpload) 90 | 91 | // add an endpoint for testing arbitrary requests 92 | mux.HandleFunc("/_test", hs.testHandler) 93 | } 94 | 95 | // testHandler echos the data sent from the client in a json object 96 | func (hs *atlasServer) testHandler(w http.ResponseWriter, r *http.Request) { 97 | 98 | req := &clientTestResp{ 99 | RawPath: r.URL.RawPath, 100 | Host: r.Host, 101 | Header: r.Header, 102 | } 103 | 104 | body, err := ioutil.ReadAll(r.Body) 105 | if err != nil { 106 | // log this, since an error should fail the test anyway 107 | hs.t.Log("error reading body:", err) 108 | } 109 | 110 | req.Body = string(body) 111 | 112 | js, _ := json.Marshal(req) 113 | if err != nil { 114 | hs.t.Log("error marshaling req:", err) 115 | } 116 | 117 | w.Write(js) 118 | } 119 | 120 | func (hs *atlasServer) statusHandler(w http.ResponseWriter, r *http.Request) { 121 | slice := strings.Split(r.URL.Path, "/") 122 | codeStr := slice[len(slice)-1] 123 | 124 | code, err := strconv.ParseInt(codeStr, 10, 32) 125 | if err != nil { 126 | hs.t.Fatal(err) 127 | } 128 | 129 | w.WriteHeader(int(code)) 130 | } 131 | 132 | func (hs *atlasServer) railsHandler(w http.ResponseWriter, r *http.Request) { 133 | w.WriteHeader(422) 134 | w.Header().Set("Content-Type", "application/json") 135 | fmt.Fprintf(w, `{"errors": ["this is an error", "this is another error"]}`) 136 | } 137 | 138 | func (hs *atlasServer) jsonHandler(w http.ResponseWriter, r *http.Request) { 139 | w.Header().Set("Content-Type", "application/json") 140 | fmt.Fprintf(w, `{"ok": true}`) 141 | } 142 | 143 | func (hs *atlasServer) authenticationHandler(w http.ResponseWriter, r *http.Request) { 144 | if err := r.ParseForm(); err != nil { 145 | hs.t.Fatal(err) 146 | } 147 | 148 | login, password := r.Form["user[login]"][0], r.Form["user[password]"][0] 149 | 150 | if login == "sethloves" && password == "bacon" { 151 | w.WriteHeader(http.StatusOK) 152 | fmt.Fprintf(w, ` 153 | { 154 | "token": "pX4AQ5vO7T-xJrxsnvlB0cfeF-tGUX-A-280LPxoryhDAbwmox7PKinMgA1F6R3BKaT" 155 | } 156 | `) 157 | } else { 158 | w.WriteHeader(http.StatusUnauthorized) 159 | } 160 | } 161 | 162 | func (hs *atlasServer) tokenHandler(w http.ResponseWriter, r *http.Request) { 163 | if r.Method != "GET" { 164 | w.WriteHeader(http.StatusMethodNotAllowed) 165 | return 166 | } 167 | 168 | token := r.Header.Get(atlasTokenHeader) 169 | if token == "a.atlasv1.b" { 170 | w.WriteHeader(http.StatusOK) 171 | } else { 172 | w.WriteHeader(http.StatusUnauthorized) 173 | } 174 | } 175 | 176 | func (hs *atlasServer) tfConfigLatest(w http.ResponseWriter, r *http.Request) { 177 | if r.Method != "GET" { 178 | w.WriteHeader(http.StatusMethodNotAllowed) 179 | return 180 | } 181 | 182 | fmt.Fprintf(w, ` 183 | { 184 | "version": { 185 | "version": 5, 186 | "metadata": { "foo": "bar" }, 187 | "variables": { "foo": "bar" } 188 | } 189 | } 190 | `) 191 | } 192 | 193 | func (hs *atlasServer) tfConfigUpload(w http.ResponseWriter, r *http.Request) { 194 | if r.Method != "POST" { 195 | w.WriteHeader(http.StatusMethodNotAllowed) 196 | return 197 | } 198 | 199 | var buf bytes.Buffer 200 | if _, err := io.Copy(&buf, r.Body); err != nil { 201 | w.WriteHeader(http.StatusMethodNotAllowed) 202 | return 203 | } 204 | 205 | if buf.Len() == 0 { 206 | w.WriteHeader(http.StatusConflict) 207 | return 208 | } 209 | 210 | uploadPath := hs.URL.String() + "/_binstore/" 211 | 212 | w.WriteHeader(http.StatusOK) 213 | fmt.Fprintf(w, ` 214 | { 215 | "version": 5, 216 | "upload_path": "%s" 217 | } 218 | `, uploadPath) 219 | } 220 | 221 | func (hs *atlasServer) vagrantArtifactExistingHandler(w http.ResponseWriter, r *http.Request) { 222 | if r.Method != "GET" { 223 | w.WriteHeader(http.StatusMethodNotAllowed) 224 | return 225 | } 226 | 227 | fmt.Fprintf(w, ` 228 | { 229 | "artifact": { 230 | "username": "hashicorp", 231 | "name": "existing", 232 | "tag": "hashicorp/existing" 233 | } 234 | } 235 | `) 236 | } 237 | 238 | func (hs *atlasServer) vagrantArtifactSearchHandler1(w http.ResponseWriter, r *http.Request) { 239 | if r.Method != "GET" { 240 | w.WriteHeader(http.StatusMethodNotAllowed) 241 | return 242 | } 243 | 244 | fmt.Fprintf(w, ` 245 | { 246 | "versions": [{ 247 | "username": "hashicorp", 248 | "name": "existing", 249 | "tag": "hashicorp/existing" 250 | }] 251 | } 252 | `) 253 | } 254 | 255 | func (hs *atlasServer) vagrantArtifactSearchHandler2(w http.ResponseWriter, r *http.Request) { 256 | if r.Method != "GET" { 257 | w.WriteHeader(http.StatusMethodNotAllowed) 258 | return 259 | } 260 | 261 | if err := r.ParseForm(); err != nil { 262 | w.WriteHeader(http.StatusMethodNotAllowed) 263 | return 264 | } 265 | 266 | if r.Form.Get("metadata.1.key") == "" { 267 | w.WriteHeader(http.StatusMethodNotAllowed) 268 | return 269 | } 270 | if r.Form.Get("metadata.2.key") == "" { 271 | w.WriteHeader(http.StatusMethodNotAllowed) 272 | return 273 | } 274 | 275 | fmt.Fprintf(w, ` 276 | { 277 | "versions": [{ 278 | "username": "hashicorp", 279 | "name": "existing", 280 | "tag": "hashicorp/existing" 281 | }] 282 | } 283 | `) 284 | } 285 | 286 | func (hs *atlasServer) vagrantArtifactUploadHandler(w http.ResponseWriter, r *http.Request) { 287 | if r.Method != "POST" { 288 | w.WriteHeader(http.StatusMethodNotAllowed) 289 | return 290 | } 291 | 292 | var buf bytes.Buffer 293 | if _, err := io.Copy(&buf, r.Body); err != nil { 294 | w.WriteHeader(http.StatusMethodNotAllowed) 295 | return 296 | } 297 | 298 | if buf.Len() == 0 { 299 | w.WriteHeader(http.StatusConflict) 300 | return 301 | } 302 | 303 | uploadPath := hs.URL.String() + "/_binstore/" 304 | 305 | w.WriteHeader(http.StatusOK) 306 | fmt.Fprintf(w, ` 307 | { 308 | "upload_path": "%s" 309 | } 310 | `, uploadPath) 311 | } 312 | 313 | func (hs *atlasServer) vagrantAppExistingHandler(w http.ResponseWriter, r *http.Request) { 314 | if r.Method != "GET" { 315 | w.WriteHeader(http.StatusMethodNotAllowed) 316 | return 317 | } 318 | 319 | fmt.Fprintf(w, ` 320 | { 321 | "username": "hashicorp", 322 | "name": "existing", 323 | "tag": "hashicorp/existing", 324 | "private": true 325 | } 326 | `) 327 | } 328 | 329 | func (hs *atlasServer) vagrantBCCreateHandler(w http.ResponseWriter, r *http.Request) { 330 | if r.Method != "POST" { 331 | w.WriteHeader(http.StatusMethodNotAllowed) 332 | return 333 | } 334 | 335 | var wrapper bcWrapper 336 | dec := json.NewDecoder(r.Body) 337 | if err := dec.Decode(&wrapper); err != nil && err != io.EOF { 338 | hs.t.Fatal(err) 339 | } 340 | bc := wrapper.BuildConfig 341 | 342 | if bc.User != "hashicorp" { 343 | w.WriteHeader(http.StatusConflict) 344 | return 345 | } 346 | 347 | w.WriteHeader(http.StatusOK) 348 | fmt.Fprintf(w, ` 349 | { 350 | "username":"hashicorp", 351 | "name":"new", 352 | "tag":"hashicorp/new", 353 | "private":true 354 | } 355 | `) 356 | } 357 | 358 | func (hs *atlasServer) vagrantBCCreateVersionHandler(w http.ResponseWriter, r *http.Request) { 359 | if r.Method != "POST" { 360 | w.WriteHeader(http.StatusMethodNotAllowed) 361 | return 362 | } 363 | 364 | var wrapper bcCreateWrapper 365 | dec := json.NewDecoder(r.Body) 366 | if err := dec.Decode(&wrapper); err != nil && err != io.EOF { 367 | hs.t.Fatal(err) 368 | } 369 | builds := wrapper.Version.Builds 370 | 371 | if len(builds) == 0 { 372 | w.WriteHeader(http.StatusConflict) 373 | return 374 | } 375 | 376 | expected := map[string]interface{}{"testing": true} 377 | if !reflect.DeepEqual(wrapper.Version.Metadata, expected) { 378 | hs.t.Fatalf("expected %q to be %q", wrapper.Version.Metadata, expected) 379 | } 380 | 381 | uploadPath := hs.URL.String() + "/_binstore/" 382 | 383 | w.WriteHeader(http.StatusOK) 384 | fmt.Fprintf(w, ` 385 | { 386 | "upload_path": "%s" 387 | } 388 | `, uploadPath) 389 | } 390 | 391 | func (hs *atlasServer) vagrantBCExistingHandler(w http.ResponseWriter, r *http.Request) { 392 | if r.Method != "GET" { 393 | w.WriteHeader(http.StatusMethodNotAllowed) 394 | return 395 | } 396 | 397 | fmt.Fprintf(w, ` 398 | { 399 | "username": "hashicorp", 400 | "name": "existing" 401 | } 402 | `) 403 | } 404 | 405 | func (hs *atlasServer) vagrantCreateAppHandler(w http.ResponseWriter, r *http.Request) { 406 | if r.Method != "POST" { 407 | w.WriteHeader(http.StatusMethodNotAllowed) 408 | return 409 | } 410 | 411 | var aw appWrapper 412 | dec := json.NewDecoder(r.Body) 413 | if err := dec.Decode(&aw); err != nil && err != io.EOF { 414 | hs.t.Fatal(err) 415 | } 416 | app := aw.Application 417 | 418 | if app.User == "hashicorp" && app.Name == "existing" { 419 | w.WriteHeader(http.StatusConflict) 420 | } else { 421 | body, err := json.Marshal(app) 422 | if err != nil { 423 | hs.t.Fatal(err) 424 | } 425 | 426 | w.WriteHeader(http.StatusOK) 427 | fmt.Fprintf(w, string(body)) 428 | } 429 | } 430 | 431 | func (hs *atlasServer) vagrantCreateAppsHandler(w http.ResponseWriter, r *http.Request) { 432 | if r.Method != "GET" { 433 | w.WriteHeader(http.StatusMethodNotAllowed) 434 | return 435 | } 436 | 437 | split := strings.Split(r.RequestURI, "/") 438 | parts := split[len(split)-2:] 439 | user, name := parts[0], parts[1] 440 | 441 | if user == "hashicorp" && name == "existing" { 442 | body, err := json.Marshal(&App{ 443 | User: "hashicorp", 444 | Name: "existing", 445 | }) 446 | if err != nil { 447 | hs.t.Fatal(err) 448 | } 449 | 450 | w.WriteHeader(http.StatusOK) 451 | fmt.Fprintf(w, string(body)) 452 | } else { 453 | w.WriteHeader(http.StatusNotFound) 454 | } 455 | } 456 | 457 | func (hs *atlasServer) vagrantUploadAppHandler(w http.ResponseWriter, r *http.Request) { 458 | u := *hs.URL 459 | u.Path = path.Join(u.Path, "_binstore/630e42d9-2364-2412-4121-18266770468e") 460 | 461 | var buf bytes.Buffer 462 | if _, err := io.Copy(&buf, r.Body); err != nil { 463 | hs.t.Fatal(err) 464 | } 465 | expected := `{"application":{"metadata":{"testing":true}}}` 466 | if buf.String() != expected { 467 | hs.t.Fatalf("expected metadata to be %q, but was %q", expected, buf.String()) 468 | } 469 | 470 | body, err := json.Marshal(&appVersion{ 471 | UploadPath: u.String(), 472 | Token: "630e42d9-2364-2412-4121-18266770468e", 473 | Version: 125, 474 | }) 475 | if err != nil { 476 | hs.t.Fatal(err) 477 | } 478 | 479 | w.WriteHeader(http.StatusOK) 480 | fmt.Fprintf(w, string(body)) 481 | } 482 | 483 | func (hs *atlasServer) binstoreHandler(w http.ResponseWriter, r *http.Request) { 484 | if r.Method != "PUT" { 485 | w.WriteHeader(http.StatusMethodNotAllowed) 486 | return 487 | } 488 | 489 | w.WriteHeader(http.StatusOK) 490 | } 491 | -------------------------------------------------------------------------------- /v1/authentication.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | // Login accepts a username and password as string arguments. Both username and 11 | // password must be non-nil, non-empty values. Atlas does not permit 12 | // passwordless authentication. 13 | // 14 | // If authentication is unsuccessful, an error is returned with the body of the 15 | // error containing the server's response. 16 | // 17 | // If authentication is successful, this method sets the Token value on the 18 | // Client and returns the Token as a string. 19 | func (c *Client) Login(username, password string) (string, error) { 20 | log.Printf("[INFO] logging in user %s", username) 21 | 22 | if len(username) == 0 { 23 | return "", fmt.Errorf("client: missing username") 24 | } 25 | 26 | if len(password) == 0 { 27 | return "", fmt.Errorf("client: missing password") 28 | } 29 | 30 | // Make a request 31 | request, err := c.Request("POST", "/api/v1/authenticate", &RequestOptions{ 32 | Body: strings.NewReader(url.Values{ 33 | "user[login]": []string{username}, 34 | "user[password]": []string{password}, 35 | "user[description]": []string{"Created by the Atlas Go Client"}, 36 | }.Encode()), 37 | Headers: map[string]string{ 38 | "Content-Type": "application/x-www-form-urlencoded", 39 | }, 40 | }) 41 | if err != nil { 42 | return "", err 43 | } 44 | 45 | // Make the request 46 | response, err := checkResp(c.HTTPClient.Do(request)) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | // Decode the body 52 | var tResponse struct{ Token string } 53 | if err := decodeJSON(response, &tResponse); err != nil { 54 | return "", nil 55 | } 56 | 57 | // Set the token 58 | log.Printf("[DEBUG] setting atlas token (%s)", maskString(tResponse.Token)) 59 | c.Token = tResponse.Token 60 | 61 | // Return the token 62 | return c.Token, nil 63 | } 64 | 65 | // Verify verifies that authentication and communication with Atlas 66 | // is properly functioning. 67 | func (c *Client) Verify() error { 68 | log.Printf("[INFO] verifying authentication") 69 | 70 | request, err := c.Request("GET", "/api/v1/authenticate", nil) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | _, err = checkResp(c.HTTPClient.Do(request)) 76 | return err 77 | } 78 | 79 | // maskString masks all but the first few characters of a string for display 80 | // output. This is useful for tokens so we can display them to the user without 81 | // showing the full output. 82 | func maskString(s string) string { 83 | if len(s) <= 3 { 84 | return "*** (masked)" 85 | } 86 | 87 | return s[0:3] + "*** (masked)" 88 | } 89 | -------------------------------------------------------------------------------- /v1/authentication_test.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import "testing" 4 | 5 | func TestMaskString_emptyString(t *testing.T) { 6 | result := maskString("") 7 | expected := "*** (masked)" 8 | 9 | if result != expected { 10 | t.Errorf("expected %s to be %s", result, expected) 11 | } 12 | } 13 | 14 | func TestMaskString_threeString(t *testing.T) { 15 | result := maskString("123") 16 | expected := "*** (masked)" 17 | 18 | if result != expected { 19 | t.Errorf("expected %s to be %s", result, expected) 20 | } 21 | } 22 | 23 | func TestMaskString_longerString(t *testing.T) { 24 | result := maskString("ABCD1234") 25 | expected := "ABC*** (masked)" 26 | 27 | if result != expected { 28 | t.Errorf("expected %s to be %s", result, expected) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /v1/build_config.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | ) 10 | 11 | // bcWrapper is the API wrapper since the server wraps the resulting object. 12 | type bcWrapper struct { 13 | BuildConfig *BuildConfig `json:"build_configuration"` 14 | } 15 | 16 | // Atlas expects a list of key/value vars 17 | type BuildVar struct { 18 | Key string `json:"key"` 19 | Value string `json:"value"` 20 | Sensitive bool `json:"sensitive"` 21 | } 22 | type BuildVars []BuildVar 23 | 24 | // BuildConfig represents a Packer build configuration. 25 | type BuildConfig struct { 26 | // User is the namespace under which the build config lives 27 | User string `json:"username"` 28 | 29 | // Name is the actual name of the build config, unique in the scope 30 | // of the username. 31 | Name string `json:"name"` 32 | } 33 | 34 | // Slug returns the slug format for this BuildConfig (User/Name) 35 | func (b *BuildConfig) Slug() string { 36 | return fmt.Sprintf("%s/%s", b.User, b.Name) 37 | } 38 | 39 | // BuildConfigVersion represents a single uploaded (or uploadable) version 40 | // of a build configuration. 41 | type BuildConfigVersion struct { 42 | // The fields below are the username/name combo to uniquely identify 43 | // a build config. 44 | User string `json:"username"` 45 | Name string `json:"name"` 46 | 47 | // Builds is the list of builds that this version supports. 48 | Builds []BuildConfigBuild 49 | } 50 | 51 | // Slug returns the slug format for this BuildConfigVersion (User/Name) 52 | func (bv *BuildConfigVersion) Slug() string { 53 | return fmt.Sprintf("%s/%s", bv.User, bv.Name) 54 | } 55 | 56 | // BuildConfigBuild is a single build that is present in an uploaded 57 | // build configuration. 58 | type BuildConfigBuild struct { 59 | // Name is a unique name for this build 60 | Name string `json:"name"` 61 | 62 | // Type is the type of builder that this build needs to run on, 63 | // such as "amazon-ebs" or "qemu". 64 | Type string `json:"type"` 65 | 66 | // Artifact is true if this build results in one or more artifacts 67 | // being sent to Atlas 68 | Artifact bool `json:"artifact"` 69 | } 70 | 71 | // BuildConfig gets a single build configuration by user and name. 72 | func (c *Client) BuildConfig(user, name string) (*BuildConfig, error) { 73 | log.Printf("[INFO] getting build configuration %s/%s", user, name) 74 | 75 | endpoint := fmt.Sprintf("/api/v1/packer/build-configurations/%s/%s", user, name) 76 | request, err := c.Request("GET", endpoint, nil) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | response, err := checkResp(c.HTTPClient.Do(request)) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | var bc BuildConfig 87 | if err := decodeJSON(response, &bc); err != nil { 88 | return nil, err 89 | } 90 | 91 | return &bc, nil 92 | } 93 | 94 | // CreateBuildConfig creates a new build configuration. 95 | func (c *Client) CreateBuildConfig(user, name string) (*BuildConfig, error) { 96 | log.Printf("[INFO] creating build configuration %s/%s", user, name) 97 | 98 | endpoint := "/api/v1/packer/build-configurations" 99 | body, err := json.Marshal(&bcWrapper{ 100 | BuildConfig: &BuildConfig{ 101 | User: user, 102 | Name: name, 103 | }, 104 | }) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | request, err := c.Request("POST", endpoint, &RequestOptions{ 110 | Body: bytes.NewReader(body), 111 | Headers: map[string]string{ 112 | "Content-Type": "application/json", 113 | }, 114 | }) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | response, err := checkResp(c.HTTPClient.Do(request)) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | var bc BuildConfig 125 | if err := decodeJSON(response, &bc); err != nil { 126 | return nil, err 127 | } 128 | 129 | return &bc, nil 130 | } 131 | 132 | // UploadBuildConfigVersion creates a single build configuration version 133 | // and uploads the template associated with it. 134 | // 135 | // Actual API: "Create Build Config Version" 136 | func (c *Client) UploadBuildConfigVersion(v *BuildConfigVersion, metadata map[string]interface{}, 137 | vars BuildVars, data io.Reader, size int64) error { 138 | 139 | log.Printf("[INFO] uploading build configuration version %s (%d bytes), with metadata %q", 140 | v.Slug(), size, metadata) 141 | 142 | endpoint := fmt.Sprintf("/api/v1/packer/build-configurations/%s/%s/versions", 143 | v.User, v.Name) 144 | 145 | var bodyData bcCreateWrapper 146 | bodyData.Version.Builds = v.Builds 147 | bodyData.Version.Metadata = metadata 148 | bodyData.Version.Vars = vars 149 | body, err := json.Marshal(bodyData) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | request, err := c.Request("POST", endpoint, &RequestOptions{ 155 | Body: bytes.NewReader(body), 156 | Headers: map[string]string{ 157 | "Content-Type": "application/json", 158 | }, 159 | }) 160 | if err != nil { 161 | return err 162 | } 163 | 164 | response, err := checkResp(c.HTTPClient.Do(request)) 165 | if err != nil { 166 | return err 167 | } 168 | 169 | var bv bcCreate 170 | if err := decodeJSON(response, &bv); err != nil { 171 | return err 172 | } 173 | 174 | if err := c.putFile(bv.UploadPath, data, size); err != nil { 175 | return err 176 | } 177 | 178 | return nil 179 | } 180 | 181 | // bcCreate is the struct returned when creating a build configuration. 182 | type bcCreate struct { 183 | UploadPath string `json:"upload_path"` 184 | } 185 | 186 | // bcCreateWrapper is the wrapper for creating a build config. 187 | type bcCreateWrapper struct { 188 | Version struct { 189 | Metadata map[string]interface{} `json:"metadata,omitempty"` 190 | Builds []BuildConfigBuild `json:"builds"` 191 | Vars BuildVars `json:"packer_vars,omitempty"` 192 | } `json:"version"` 193 | } 194 | -------------------------------------------------------------------------------- /v1/build_config_test.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestBuildConfig_slug(t *testing.T) { 10 | bc := &BuildConfig{User: "sethvargo", Name: "bacon"} 11 | expected := "sethvargo/bacon" 12 | if bc.Slug() != expected { 13 | t.Errorf("expected %q to be %q", bc.Slug(), expected) 14 | } 15 | } 16 | 17 | func TestBuildConfigVersion_slug(t *testing.T) { 18 | bc := &BuildConfigVersion{User: "sethvargo", Name: "bacon"} 19 | expected := "sethvargo/bacon" 20 | if bc.Slug() != expected { 21 | t.Errorf("expected %q to be %q", bc.Slug(), expected) 22 | } 23 | } 24 | 25 | func TestBuildConfig_fetches(t *testing.T) { 26 | server := newTestAtlasServer(t) 27 | defer server.Stop() 28 | 29 | client, err := NewClient(server.URL.String()) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | actual, err := client.BuildConfig("hashicorp", "existing") 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | expected := &BuildConfig{ 40 | User: "hashicorp", 41 | Name: "existing", 42 | } 43 | 44 | if !reflect.DeepEqual(actual, expected) { 45 | t.Fatalf("%#v", actual) 46 | } 47 | } 48 | 49 | func TestCreateBuildConfig(t *testing.T) { 50 | server := newTestAtlasServer(t) 51 | defer server.Stop() 52 | 53 | client, err := NewClient(server.URL.String()) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | user, name := "hashicorp", "new" 59 | bc, err := client.CreateBuildConfig(user, name) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | if bc.User != user { 65 | t.Errorf("expected %q to be %q", bc.User, user) 66 | } 67 | 68 | if bc.Name != name { 69 | t.Errorf("expected %q to be %q", bc.Name, name) 70 | } 71 | } 72 | 73 | func TestUploadBuildConfigVersion(t *testing.T) { 74 | server := newTestAtlasServer(t) 75 | defer server.Stop() 76 | 77 | client, err := NewClient(server.URL.String()) 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | 82 | bc := &BuildConfigVersion{ 83 | User: "hashicorp", 84 | Name: "existing", 85 | Builds: []BuildConfigBuild{ 86 | BuildConfigBuild{Name: "foo", Type: "ami"}, 87 | }, 88 | } 89 | metadata := map[string]interface{}{"testing": true} 90 | vars := BuildVars{BuildVar{Key: "one", Value: "two"}} 91 | data := new(bytes.Buffer) 92 | err = client.UploadBuildConfigVersion(bc, metadata, vars, data, int64(data.Len())) 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /v1/client.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "path" 14 | "runtime" 15 | "strings" 16 | 17 | "github.com/hashicorp/go-cleanhttp" 18 | "github.com/hashicorp/go-rootcerts" 19 | ) 20 | 21 | const ( 22 | // atlasDefaultEndpoint is the default base URL for connecting to Atlas. 23 | atlasDefaultEndpoint = "https://atlas.hashicorp.com" 24 | 25 | // atlasEndpointEnvVar is the environment variable that overrrides the 26 | // default Atlas address. 27 | atlasEndpointEnvVar = "ATLAS_ADDRESS" 28 | 29 | // atlasCAFileEnvVar is the environment variable that causes the client to 30 | // load trusted certs from a file 31 | atlasCAFileEnvVar = "ATLAS_CAFILE" 32 | 33 | // atlasCAPathEnvVar is the environment variable that causes the client to 34 | // load trusted certs from a directory 35 | atlasCAPathEnvVar = "ATLAS_CAPATH" 36 | 37 | // atlasTLSNoVerifyEnvVar disables TLS verification, similar to curl -k 38 | // This defaults to false (verify) and will change to true (skip 39 | // verification) with any non-empty value 40 | atlasTLSNoVerifyEnvVar = "ATLAS_TLS_NOVERIFY" 41 | 42 | // atlasTokenHeader is the header key used for authenticating with Atlas 43 | atlasTokenHeader = "X-Atlas-Token" 44 | ) 45 | 46 | var projectURL = "https://github.com/hashicorp/atlas-go" 47 | var userAgent = fmt.Sprintf("AtlasGo/1.0 (+%s; %s)", 48 | projectURL, runtime.Version()) 49 | 50 | // ErrAuth is the error returned if a 401 is returned by an API request. 51 | var ErrAuth = fmt.Errorf("authentication failed") 52 | 53 | // ErrNotFound is the error returned if a 404 is returned by an API request. 54 | var ErrNotFound = fmt.Errorf("resource not found") 55 | 56 | // RailsError represents an error that was returned from the Rails server. 57 | type RailsError struct { 58 | Errors []string `json:"errors"` 59 | } 60 | 61 | // Error collects all of the errors in the RailsError and returns a comma- 62 | // separated list of the errors that were returned from the server. 63 | func (re *RailsError) Error() string { 64 | return strings.Join(re.Errors, ", ") 65 | } 66 | 67 | // Client represents a single connection to a Atlas API endpoint. 68 | type Client struct { 69 | // URL is the full endpoint address to the Atlas server including the 70 | // protocol, port, and path. 71 | URL *url.URL 72 | 73 | // Token is the Atlas authentication token 74 | Token string 75 | 76 | // HTTPClient is the underlying http client with which to make requests. 77 | HTTPClient *http.Client 78 | 79 | // DefaultHeaders is a set of headers that will be added to every request. 80 | // This minimally includes the atlas user-agent string. 81 | DefaultHeader http.Header 82 | } 83 | 84 | // DefaultClient returns a client that connects to the Atlas API. 85 | func DefaultClient() *Client { 86 | atlasEndpoint := os.Getenv(atlasEndpointEnvVar) 87 | if atlasEndpoint == "" { 88 | atlasEndpoint = atlasDefaultEndpoint 89 | } 90 | 91 | client, err := NewClient(atlasEndpoint) 92 | if err != nil { 93 | panic(err) 94 | } 95 | 96 | return client 97 | } 98 | 99 | // NewClient creates a new Atlas Client from the given URL (as a string). If 100 | // the URL cannot be parsed, an error is returned. The HTTPClient is set to 101 | // an empty http.Client, but this can be changed programmatically by setting 102 | // client.HTTPClient. The user can also programmatically set the URL as a 103 | // *url.URL. 104 | func NewClient(urlString string) (*Client, error) { 105 | if len(urlString) == 0 { 106 | return nil, fmt.Errorf("client: missing url") 107 | } 108 | 109 | parsedURL, err := url.Parse(urlString) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | token := os.Getenv("ATLAS_TOKEN") 115 | if token != "" { 116 | log.Printf("[DEBUG] using ATLAS_TOKEN (%s)", maskString(token)) 117 | } 118 | 119 | client := &Client{ 120 | URL: parsedURL, 121 | Token: token, 122 | DefaultHeader: make(http.Header), 123 | } 124 | 125 | client.DefaultHeader.Set("User-Agent", userAgent) 126 | 127 | if err := client.init(); err != nil { 128 | return nil, err 129 | } 130 | 131 | return client, nil 132 | } 133 | 134 | // init() sets defaults on the client. 135 | func (c *Client) init() error { 136 | c.HTTPClient = cleanhttp.DefaultClient() 137 | tlsConfig := &tls.Config{} 138 | if os.Getenv(atlasTLSNoVerifyEnvVar) != "" { 139 | tlsConfig.InsecureSkipVerify = true 140 | } 141 | err := rootcerts.ConfigureTLS(tlsConfig, &rootcerts.Config{ 142 | CAFile: os.Getenv(atlasCAFileEnvVar), 143 | CAPath: os.Getenv(atlasCAPathEnvVar), 144 | }) 145 | if err != nil { 146 | return err 147 | } 148 | t := cleanhttp.DefaultTransport() 149 | t.TLSClientConfig = tlsConfig 150 | c.HTTPClient.Transport = t 151 | return nil 152 | } 153 | 154 | // RequestOptions is the list of options to pass to the request. 155 | type RequestOptions struct { 156 | // Params is a map of key-value pairs that will be added to the Request. 157 | Params map[string]string 158 | 159 | // Headers is a map of key-value pairs that will be added to the Request. 160 | Headers map[string]string 161 | 162 | // Body is an io.Reader object that will be streamed or uploaded with the 163 | // Request. BodyLength is the final size of the Body. 164 | Body io.Reader 165 | BodyLength int64 166 | } 167 | 168 | // Request creates a new HTTP request using the given verb and sub path. 169 | func (c *Client) Request(verb, spath string, ro *RequestOptions) (*http.Request, error) { 170 | log.Printf("[INFO] request: %s %s", verb, spath) 171 | 172 | // Ensure we have a RequestOptions struct (passing nil is an acceptable) 173 | if ro == nil { 174 | ro = new(RequestOptions) 175 | } 176 | 177 | // Create a new URL with the appended path 178 | u := *c.URL 179 | u.Path = path.Join(c.URL.Path, spath) 180 | 181 | // Add the token and other params 182 | if c.Token != "" { 183 | log.Printf("[DEBUG] request: appending token (%s)", maskString(c.Token)) 184 | if ro.Headers == nil { 185 | ro.Headers = make(map[string]string) 186 | } 187 | 188 | ro.Headers[atlasTokenHeader] = c.Token 189 | } 190 | 191 | return c.rawRequest(verb, &u, ro) 192 | } 193 | 194 | func (c *Client) putFile(rawURL string, r io.Reader, size int64) error { 195 | log.Printf("[INFO] putting file: %s", rawURL) 196 | 197 | url, err := url.Parse(rawURL) 198 | if err != nil { 199 | return err 200 | } 201 | 202 | request, err := c.rawRequest("PUT", url, &RequestOptions{ 203 | Body: r, 204 | BodyLength: size, 205 | }) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | if _, err := checkResp(c.HTTPClient.Do(request)); err != nil { 211 | return err 212 | } 213 | 214 | return nil 215 | } 216 | 217 | // rawRequest accepts a verb, URL, and RequestOptions struct and returns the 218 | // constructed http.Request and any errors that occurred 219 | func (c *Client) rawRequest(verb string, u *url.URL, ro *RequestOptions) (*http.Request, error) { 220 | if verb == "" { 221 | return nil, fmt.Errorf("client: missing verb") 222 | } 223 | 224 | if u == nil { 225 | return nil, fmt.Errorf("client: missing URL.url") 226 | } 227 | 228 | if ro == nil { 229 | return nil, fmt.Errorf("client: missing RequestOptions") 230 | } 231 | 232 | // Add the token and other params 233 | var params = make(url.Values) 234 | for k, v := range ro.Params { 235 | params.Add(k, v) 236 | } 237 | u.RawQuery = params.Encode() 238 | 239 | // Create the request object 240 | request, err := http.NewRequest(verb, u.String(), ro.Body) 241 | if err != nil { 242 | return nil, err 243 | } 244 | 245 | // set our default headers first 246 | for k, v := range c.DefaultHeader { 247 | request.Header[k] = v 248 | } 249 | 250 | // Add any request headers (auth will be here if set) 251 | for k, v := range ro.Headers { 252 | request.Header.Add(k, v) 253 | } 254 | 255 | // Add content-length if we have it 256 | if ro.BodyLength > 0 { 257 | request.ContentLength = ro.BodyLength 258 | } 259 | 260 | log.Printf("[DEBUG] raw request: %#v", request) 261 | 262 | return request, nil 263 | } 264 | 265 | // checkResp wraps http.Client.Do() and verifies that the request was 266 | // successful. A non-200 request returns an error formatted to included any 267 | // validation problems or otherwise. 268 | func checkResp(resp *http.Response, err error) (*http.Response, error) { 269 | // If the err is already there, there was an error higher up the chain, so 270 | // just return that 271 | if err != nil { 272 | return resp, err 273 | } 274 | 275 | log.Printf("[INFO] response: %d (%s)", resp.StatusCode, resp.Status) 276 | var buf bytes.Buffer 277 | if _, err := io.Copy(&buf, resp.Body); err != nil { 278 | log.Printf("[ERR] response: error copying response body") 279 | } else { 280 | log.Printf("[DEBUG] response: %s", buf.String()) 281 | 282 | // We are going to reset the response body, so we need to close the old 283 | // one or else it will leak. 284 | resp.Body.Close() 285 | resp.Body = &bytesReadCloser{&buf} 286 | } 287 | 288 | switch resp.StatusCode { 289 | case 200: 290 | return resp, nil 291 | case 201: 292 | return resp, nil 293 | case 202: 294 | return resp, nil 295 | case 204: 296 | return resp, nil 297 | case 400: 298 | return nil, parseErr(resp) 299 | case 401: 300 | return nil, ErrAuth 301 | case 404: 302 | return nil, ErrNotFound 303 | case 422: 304 | return nil, parseErr(resp) 305 | default: 306 | return nil, fmt.Errorf("client: %s", resp.Status) 307 | } 308 | } 309 | 310 | // parseErr is used to take an error JSON response and return a single string 311 | // for use in error messages. 312 | func parseErr(r *http.Response) error { 313 | re := &RailsError{} 314 | 315 | if err := decodeJSON(r, &re); err != nil { 316 | return fmt.Errorf("error decoding JSON body: %s", err) 317 | } 318 | 319 | return re 320 | } 321 | 322 | // decodeJSON is used to JSON decode a body into an interface. 323 | func decodeJSON(resp *http.Response, out interface{}) error { 324 | defer resp.Body.Close() 325 | dec := json.NewDecoder(resp.Body) 326 | return dec.Decode(out) 327 | } 328 | 329 | // bytesReadCloser is a simple wrapper around a bytes buffer that implements 330 | // Close as a noop. 331 | type bytesReadCloser struct { 332 | *bytes.Buffer 333 | } 334 | 335 | func (nrc *bytesReadCloser) Close() error { 336 | // we don't actually have to do anything here, since the buffer is just some 337 | // data in memory and the error is initialized to no-error 338 | return nil 339 | } 340 | -------------------------------------------------------------------------------- /v1/client_test.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "os" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestDefaultClient_url(t *testing.T) { 13 | client := DefaultClient() 14 | 15 | if client.URL.String() != atlasDefaultEndpoint { 16 | t.Fatalf("expected %q to be %q", client.URL.String(), atlasDefaultEndpoint) 17 | } 18 | } 19 | 20 | func TestDefaultClient_urlFromEnvVar(t *testing.T) { 21 | defer os.Setenv(atlasEndpointEnvVar, os.Getenv(atlasEndpointEnvVar)) 22 | otherEndpoint := "http://127.0.0.1:1234" 23 | 24 | err := os.Setenv(atlasEndpointEnvVar, otherEndpoint) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | client := DefaultClient() 30 | 31 | if client.URL.String() != otherEndpoint { 32 | t.Fatalf("expected %q to be %q", client.URL.String(), otherEndpoint) 33 | } 34 | } 35 | 36 | func TestNewClient_badURL(t *testing.T) { 37 | _, err := NewClient("") 38 | if err == nil { 39 | t.Fatal("expected error, but nothing was returned") 40 | } 41 | 42 | expected := "client: missing url" 43 | if !strings.Contains(err.Error(), expected) { 44 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 45 | } 46 | } 47 | 48 | func TestNewClient_parsesURL(t *testing.T) { 49 | client, err := NewClient("https://example.com/foo/bar") 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | expected := &url.URL{ 55 | Scheme: "https", 56 | Host: "example.com", 57 | Path: "/foo/bar", 58 | } 59 | if !reflect.DeepEqual(client.URL, expected) { 60 | t.Fatalf("expected %q to equal %q", client.URL, expected) 61 | } 62 | } 63 | 64 | func TestNewClient_TLSVerify(t *testing.T) { 65 | client, err := NewClient("https://example.com/foo/bar") 66 | 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | if client.HTTPClient.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify != false { 71 | t.Fatal("Expected InsecureSkipVerify to be false") 72 | } 73 | } 74 | 75 | func TestNewClient_TLSNoVerify(t *testing.T) { 76 | os.Setenv("ATLAS_TLS_NOVERIFY", "1") 77 | client, err := NewClient("https://example.com/foo/bar") 78 | 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | if client.HTTPClient.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify != true { 83 | t.Fatal("Expected InsecureSkipVerify to be true when ATLAS_TLS_NOVERIFY is set") 84 | } 85 | os.Setenv("ATLAS_TLS_NOVERIFY", "") 86 | } 87 | 88 | func TestNewClient_setsDefaultHTTPClient(t *testing.T) { 89 | _, err := NewClient("https://example.com/foo/bar") 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | } 94 | 95 | func TestLogin_missingUsername(t *testing.T) { 96 | client, err := NewClient("https://example.com/foo/bar") 97 | if err != nil { 98 | t.Fatal(err) 99 | } 100 | 101 | _, err = client.Login("", "") 102 | if err == nil { 103 | t.Fatal("expected error, but nothing was returned") 104 | } 105 | 106 | expected := "client: missing username" 107 | if !strings.Contains(err.Error(), expected) { 108 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 109 | } 110 | } 111 | 112 | func TestLogin_missingPassword(t *testing.T) { 113 | client, err := NewClient("https://example.com/foo/bar") 114 | if err != nil { 115 | t.Fatal(err) 116 | } 117 | 118 | _, err = client.Login("username", "") 119 | if err == nil { 120 | t.Fatal("expected error, but nothing was returned") 121 | } 122 | 123 | expected := "client: missing password" 124 | if !strings.Contains(err.Error(), expected) { 125 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 126 | } 127 | } 128 | 129 | func TestLogin_serverErrorMessage(t *testing.T) { 130 | server := newTestAtlasServer(t) 131 | defer server.Stop() 132 | 133 | client, err := NewClient(server.URL.String()) 134 | if err != nil { 135 | t.Fatal(err) 136 | } 137 | 138 | _, err = client.Login("username", "password") 139 | if err == nil { 140 | t.Fatal("expected error, but nothing was returned") 141 | } 142 | 143 | if err != ErrAuth { 144 | t.Fatalf("bad: %s", err) 145 | } 146 | } 147 | 148 | func TestLogin_success(t *testing.T) { 149 | server := newTestAtlasServer(t) 150 | defer server.Stop() 151 | 152 | client, err := NewClient(server.URL.String()) 153 | if err != nil { 154 | t.Fatal(err) 155 | } 156 | 157 | token, err := client.Login("sethloves", "bacon") 158 | if err != nil { 159 | t.Fatal(err) 160 | } 161 | 162 | if client.Token == "" { 163 | t.Fatal("expected client token to be set") 164 | } 165 | 166 | if token == "" { 167 | t.Fatal("expected token to be returned") 168 | } 169 | } 170 | 171 | func TestRequest_tokenAuth(t *testing.T) { 172 | server := newTestAtlasServer(t) 173 | defer server.Stop() 174 | 175 | client, err := NewClient(server.URL.String()) 176 | if err != nil { 177 | t.Fatal(err) 178 | } 179 | client.Token = "a.atlasv1.b" 180 | 181 | request, err := client.Request("GET", "/api/v1/token", nil) 182 | if err != nil { 183 | t.Fatal(err) 184 | } 185 | 186 | _, err = checkResp(client.HTTPClient.Do(request)) 187 | if err != nil { 188 | t.Fatal(err) 189 | } 190 | } 191 | 192 | func TestRequest_getsData(t *testing.T) { 193 | server := newTestAtlasServer(t) 194 | defer server.Stop() 195 | 196 | client, err := NewClient(server.URL.String()) 197 | if err != nil { 198 | t.Fatal(err) 199 | } 200 | 201 | request, err := client.Request("GET", "/_status/200", nil) 202 | if err != nil { 203 | t.Fatal(err) 204 | } 205 | 206 | if _, err := checkResp(client.HTTPClient.Do(request)); err != nil { 207 | t.Fatal(err) 208 | } 209 | } 210 | 211 | func TestRequest_railsError(t *testing.T) { 212 | server := newTestAtlasServer(t) 213 | defer server.Stop() 214 | 215 | client, err := NewClient(server.URL.String()) 216 | if err != nil { 217 | t.Fatal(err) 218 | } 219 | 220 | request, err := client.Request("GET", "/_rails-error", nil) 221 | if err != nil { 222 | t.Fatal(err) 223 | } 224 | 225 | _, err = checkResp(client.HTTPClient.Do(request)) 226 | if err == nil { 227 | t.Fatal("expected error, but nothing was returned") 228 | } 229 | 230 | expected := &RailsError{ 231 | Errors: []string{ 232 | "this is an error", 233 | "this is another error", 234 | }, 235 | } 236 | 237 | if !reflect.DeepEqual(err, expected) { 238 | t.Fatalf("expected %+v to be %+v", err, expected) 239 | } 240 | } 241 | 242 | func TestRequest_notFoundError(t *testing.T) { 243 | server := newTestAtlasServer(t) 244 | defer server.Stop() 245 | 246 | client, err := NewClient(server.URL.String()) 247 | if err != nil { 248 | t.Fatal(err) 249 | } 250 | 251 | request, err := client.Request("GET", "/_status/404", nil) 252 | if err != nil { 253 | t.Fatal(err) 254 | } 255 | 256 | _, err = checkResp(client.HTTPClient.Do(request)) 257 | if err == nil { 258 | t.Fatal("expected error, but nothing was returned") 259 | } 260 | 261 | if err != ErrNotFound { 262 | t.Fatalf("bad error: %#v", err) 263 | } 264 | } 265 | 266 | func TestRequestJSON_decodesData(t *testing.T) { 267 | server := newTestAtlasServer(t) 268 | defer server.Stop() 269 | 270 | client, err := NewClient(server.URL.String()) 271 | if err != nil { 272 | t.Fatal(err) 273 | } 274 | 275 | request, err := client.Request("GET", "/_json", nil) 276 | if err != nil { 277 | t.Fatal(err) 278 | } 279 | 280 | response, err := checkResp(client.HTTPClient.Do(request)) 281 | if err != nil { 282 | t.Fatal(err) 283 | } 284 | 285 | var decoded struct{ Ok bool } 286 | if err := decodeJSON(response, &decoded); err != nil { 287 | t.Fatal(err) 288 | } 289 | 290 | if !decoded.Ok { 291 | t.Fatal("expected decoded response to be Ok, but was not") 292 | } 293 | } 294 | 295 | // check that our DefaultHeader works correctly, along with it providing 296 | // User-Agent 297 | func TestClient_defaultHeaders(t *testing.T) { 298 | server := newTestAtlasServer(t) 299 | defer server.Stop() 300 | 301 | client, err := NewClient(server.URL.String()) 302 | if err != nil { 303 | t.Fatal(err) 304 | } 305 | 306 | testHeader := "Atlas-Test" 307 | testHeaderVal := "default header test" 308 | client.DefaultHeader.Set(testHeader, testHeaderVal) 309 | 310 | request, err := client.Request("GET", "/_test", nil) 311 | if err != nil { 312 | t.Fatal(err) 313 | } 314 | 315 | response, err := checkResp(client.HTTPClient.Do(request)) 316 | if err != nil { 317 | t.Fatal(err) 318 | } 319 | 320 | decoded := &clientTestResp{} 321 | if err := decodeJSON(response, &decoded); err != nil { 322 | t.Fatal(err) 323 | } 324 | 325 | // Make sure User-Agent is set correctly 326 | if decoded.Header.Get("User-Agent") != userAgent { 327 | t.Fatal("User-Agent reported as", decoded.Header.Get("User-Agent")) 328 | } 329 | 330 | // look for our test header 331 | if decoded.Header.Get(testHeader) != testHeaderVal { 332 | t.Fatalf("DefaultHeader %q reported as %q", testHeader, testHeaderVal) 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /v1/terraform.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | ) 10 | 11 | // TerraformConfigVersion represents a single uploaded version of a 12 | // Terraform configuration. 13 | type TerraformConfigVersion struct { 14 | Version int 15 | Remotes []string `json:"remotes"` 16 | Metadata map[string]string `json:"metadata"` 17 | Variables map[string]string `json:"variables,omitempty"` 18 | TFVars []TFVar `json:"tf_vars"` 19 | } 20 | 21 | // TFVar is used to serialize a single Terraform variable sent by the 22 | // manager as a collection of Variables in a Job payload. 23 | type TFVar struct { 24 | Key string `json:"key"` 25 | Value string `json:"value"` 26 | IsHCL bool `json:"hcl"` 27 | } 28 | 29 | // TerraformConfigLatest returns the latest Terraform configuration version. 30 | func (c *Client) TerraformConfigLatest(user, name string) (*TerraformConfigVersion, error) { 31 | log.Printf("[INFO] getting terraform configuration %s/%s", user, name) 32 | 33 | endpoint := fmt.Sprintf("/api/v1/terraform/configurations/%s/%s/versions/latest", user, name) 34 | request, err := c.Request("GET", endpoint, nil) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | response, err := checkResp(c.HTTPClient.Do(request)) 40 | if err == ErrNotFound { 41 | return nil, nil 42 | } 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | var wrapper tfConfigVersionWrapper 48 | if err := decodeJSON(response, &wrapper); err != nil { 49 | return nil, err 50 | } 51 | 52 | return wrapper.Version, nil 53 | } 54 | 55 | // CreateTerraformConfigVersion creatse a new Terraform configuration 56 | // versions and uploads a slug with it. 57 | func (c *Client) CreateTerraformConfigVersion( 58 | user string, name string, 59 | version *TerraformConfigVersion, 60 | data io.Reader, size int64) (int, error) { 61 | log.Printf("[INFO] creating terraform configuration %s/%s", user, name) 62 | 63 | endpoint := fmt.Sprintf( 64 | "/api/v1/terraform/configurations/%s/%s/versions", user, name) 65 | body, err := json.Marshal(&tfConfigVersionWrapper{ 66 | Version: version, 67 | }) 68 | if err != nil { 69 | return 0, err 70 | } 71 | 72 | request, err := c.Request("POST", endpoint, &RequestOptions{ 73 | Body: bytes.NewReader(body), 74 | Headers: map[string]string{ 75 | "Content-Type": "application/json", 76 | }, 77 | }) 78 | if err != nil { 79 | return 0, err 80 | } 81 | 82 | response, err := checkResp(c.HTTPClient.Do(request)) 83 | if err != nil { 84 | return 0, err 85 | } 86 | 87 | var result tfConfigVersionCreate 88 | if err := decodeJSON(response, &result); err != nil { 89 | return 0, err 90 | } 91 | 92 | if err := c.putFile(result.UploadPath, data, size); err != nil { 93 | return 0, err 94 | } 95 | 96 | return result.Version, nil 97 | } 98 | 99 | type tfConfigVersionCreate struct { 100 | UploadPath string `json:"upload_path"` 101 | Version int 102 | } 103 | 104 | type tfConfigVersionWrapper struct { 105 | Version *TerraformConfigVersion `json:"version"` 106 | } 107 | -------------------------------------------------------------------------------- /v1/terraform_test.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "bytes" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestTerraformConfigLatest(t *testing.T) { 10 | server := newTestAtlasServer(t) 11 | defer server.Stop() 12 | 13 | client, err := NewClient(server.URL.String()) 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | 18 | actual, err := client.TerraformConfigLatest("hashicorp", "existing") 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | 23 | expected := &TerraformConfigVersion{ 24 | Version: 5, 25 | Metadata: map[string]string{"foo": "bar"}, 26 | Variables: map[string]string{"foo": "bar"}, 27 | } 28 | 29 | if !reflect.DeepEqual(actual, expected) { 30 | t.Fatalf("%#v", actual) 31 | } 32 | } 33 | 34 | func TestCreateTerraformConfigVersion(t *testing.T) { 35 | server := newTestAtlasServer(t) 36 | defer server.Stop() 37 | 38 | client, err := NewClient(server.URL.String()) 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | v := &TerraformConfigVersion{ 44 | Version: 5, 45 | Metadata: map[string]string{"foo": "bar"}, 46 | } 47 | 48 | data := new(bytes.Buffer) 49 | vsn, err := client.CreateTerraformConfigVersion( 50 | "hashicorp", "existing", v, data, int64(data.Len())) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | if vsn != 5 { 55 | t.Fatalf("bad: %v", vsn) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /v1/util.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // ParseSlug parses a slug of the format (x/y) into the x and y components. It 9 | // accepts a string of the format "x/y" ("user/name" for example). If an empty 10 | // string is given, an error is returned. If the given string is not a valid 11 | // slug format, an error is returned. 12 | func ParseSlug(slug string) (string, string, error) { 13 | if slug == "" { 14 | return "", "", fmt.Errorf("missing slug") 15 | } 16 | 17 | parts := strings.Split(slug, "/") 18 | if len(parts) != 2 { 19 | return "", "", fmt.Errorf("malformed slug %q", slug) 20 | } 21 | return parts[0], parts[1], nil 22 | } 23 | -------------------------------------------------------------------------------- /v1/util_test.go: -------------------------------------------------------------------------------- 1 | package atlas 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func TestParseSlug_emptyString(t *testing.T) { 9 | _, _, err := ParseSlug("") 10 | if err == nil { 11 | t.Fatal("expected error, but nothing was returned") 12 | } 13 | 14 | expected := "missing slug" 15 | if !strings.Contains(err.Error(), expected) { 16 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 17 | } 18 | } 19 | 20 | func TestParseSlug_noSlashes(t *testing.T) { 21 | _, _, err := ParseSlug("bacon") 22 | if err == nil { 23 | t.Fatal("expected error, but nothing was returned") 24 | } 25 | 26 | expected := "malformed slug" 27 | if !strings.Contains(err.Error(), expected) { 28 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 29 | } 30 | } 31 | 32 | func TestParseSlug_multipleSlashes(t *testing.T) { 33 | _, _, err := ParseSlug("bacon/is/delicious/but/this/is/not/valid") 34 | if err == nil { 35 | t.Fatal("expected error, but nothing was returned") 36 | } 37 | 38 | expected := "malformed slug" 39 | if !strings.Contains(err.Error(), expected) { 40 | t.Fatalf("expected %q to contain %q", err.Error(), expected) 41 | } 42 | } 43 | 44 | func TestParseSlug_goodString(t *testing.T) { 45 | user, name, err := ParseSlug("hashicorp/project") 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | if user != "hashicorp" { 51 | t.Fatalf("expected %q to be %q", user, "hashicorp") 52 | } 53 | 54 | if name != "project" { 55 | t.Fatalf("expected %q to be %q", name, "project") 56 | } 57 | } 58 | --------------------------------------------------------------------------------