├── .gitignore ├── .travis.yml ├── Godeps └── Godeps.json ├── LICENSE ├── Makefile ├── README.md ├── cloudflare.go ├── cloudflare_query.go ├── cloudflare_query_test.go ├── cloudflare_test.go ├── config_items.go ├── config_items_test.go ├── jenkins.sh ├── main.go ├── main_suite_test.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | # Godeps 27 | Godeps/_workspace 28 | Godeps/Readme 29 | 30 | # Project-specific 31 | cloudflare-configure 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.2 5 | - 1.3 6 | 7 | before_install: 8 | - export PATH=$HOME/gopath/bin:$PATH 9 | 10 | notifications: 11 | email: false 12 | -------------------------------------------------------------------------------- /Godeps/Godeps.json: -------------------------------------------------------------------------------- 1 | { 2 | "ImportPath": "github.com/alphagov/cloudflare-configure", 3 | "GoVersion": "go1.2.1", 4 | "Deps": [ 5 | { 6 | "ImportPath": "github.com/jwaldrip/odin/cli/values", 7 | "Comment": "v1.5.0", 8 | "Rev": "355cc891cfa6999bfdbcf8432b211762c226244d" 9 | }, 10 | { 11 | "ImportPath": "github.com/onsi/ginkgo", 12 | "Comment": "v1.1.0-20-g75d0cd9", 13 | "Rev": "75d0cd9c7ee52e99b13e6ccfd9e58b4f92b7795e" 14 | }, 15 | { 16 | "ImportPath": "github.com/onsi/gomega", 17 | "Comment": "v1.0-19-g835b5e4", 18 | "Rev": "835b5e4242c715976b98ed6bc6ece1d9c7879f66" 19 | }, 20 | { 21 | "ImportPath": "gopkg.in/jwaldrip/odin.v1/cli", 22 | "Comment": "v1.5.0", 23 | "Rev": "355cc891cfa6999bfdbcf8432b211762c226244d" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2014 Crown (Government Digital Service) 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: deps test build 2 | 3 | BINARY := cloudflare-configure 4 | 5 | all: deps test build 6 | 7 | deps: 8 | go get github.com/tools/godep 9 | godep restore 10 | 11 | test: deps 12 | godep go test 13 | 14 | build: deps 15 | godep go build -o $(BINARY) 16 | 17 | clean: 18 | rm -rf $(BINARY) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudFlare Configure 2 | 3 | A utility to configure [CloudFlare CDN] settings from version-controllable 4 | JSON files and using [CloudFlare's v4 API]. 5 | 6 | [CloudFlare CDN]: https://www.cloudflare.com/features-cdn 7 | [CloudFlare's v4 API]: http://developers.cloudflare.com/next/ 8 | 9 | ## Compiling 10 | 11 | You will need [Go]. Any 1.x version should do. To compile the binary: 12 | 13 | make 14 | 15 | [Go]: http://golang.org/ 16 | 17 | ## Usage 18 | 19 | Set some environment variables. These are *not* required, but they simplify 20 | our examples and prevent the key from being stored in your shell history: 21 | 22 | ➜ cdn-configs git:(master) export CF_EMAIL=user@example.com 23 | ➜ cdn-configs git:(master) export CF_KEY=b58996c504c5638798eb6b511e6f49af 24 | 25 | List the available zones: 26 | 27 | ➜ cdn-configs git:(master) ./cloudflare-configure --email ${CF_EMAIL} --key ${CF_KEY} zones 28 | 4986183da7c16aab483d31ac6bb4cb7b foo.example.com 29 | 15f14360e93a76824ab7d49a4533d970 bar.example.com 30 | d1082145f48bb35a023c6ec3a7897837 baz.example.com 31 | 32 | Download the current configuration for a zone: 33 | 34 | ➜ cdn-configs git:(master) ./cloudflare-configure --email ${CF_EMAIL} --key ${CF_KEY} download 4986183da7c16aab483d31ac6bb4cb7b myzone.json 35 | 2014/10/17 14:01:54 Saving config to: myzone.json 36 | 37 | Modify some settings: 38 | 39 | ➜ cdn-configs git:(master) gsed -ri 's/("ipv6": )"off"/\1"on"/' myzone.json 40 | ➜ cdn-configs git:(master) gsed -ri 's/("browser_cache_ttl": )14400/\17200/' myzone.json 41 | 42 | Review the changes: 43 | 44 | ➜ cdn-configs git:(master) ./cloudflare-configure --email ${CF_EMAIL} --key ${CF_KEY} upload 4986183da7c16aab483d31ac6bb4cb7b myzone.json --dry-run 45 | 2014/10/17 14:16:06 Would have changed setting "ipv6" from "off" to "on" 46 | 2014/10/17 14:16:06 Would have changed setting "browser_cache_ttl" from 14400 to 7200 47 | 48 | Upload the changes: 49 | 50 | ➜ cdn-configs git:(master) ./cloudflare-configure --email ${CF_EMAIL} --key ${CF_KEY} upload 4986183da7c16aab483d31ac6bb4cb7b myzone.json 51 | 2014/10/17 14:16:15 Changing setting "ipv6" from "off" to "on" 52 | 2014/10/17 14:16:16 Changing setting "browser_cache_ttl" from 14400 to 7200 53 | 54 | Use the `--help` argument to see all of the sub-commands and flags available. 55 | 56 | ## Considerations 57 | 58 | The following caveats and limitations should be borne in mind: 59 | 60 | - It can't manage "page rules", which are used to configure protocol 61 | redirects or caching of all content types, because they aren't currently 62 | supported by the API. 63 | - If the key names in the local and remote configurations differ, for 64 | example if you have made a typo or CloudFlare introduce a new feature, 65 | then a message will be logged and you will need to update your 66 | configuration manually (compare with `-download`). 67 | - It is assumed that any keys that need modifying have API endpoints of the 68 | same name, eg. `{"id":"always_online",…}` can be written at 69 | `/v4/zones/123/settings/always_online`. This appears to hold true. 70 | - It doesn't respect `{"editable":false,…}"` which CloudFlare use to 71 | indicate that a key is immutable. Such keys probably don't have write 72 | endpoints (see the last point) and will fail, but they should also never 73 | change in your config. 74 | - It will abort and cease to make any other changes as soon as it encounters 75 | an error. 76 | -------------------------------------------------------------------------------- /cloudflare.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | ) 11 | 12 | type CloudFlareError struct { 13 | Code int 14 | Message string 15 | } 16 | 17 | type CloudFlareResponse struct { 18 | Success bool 19 | Errors []CloudFlareError 20 | Messages []string 21 | Result json.RawMessage 22 | } 23 | 24 | type CloudFlareZoneItem struct { 25 | ID string 26 | Name string 27 | } 28 | 29 | type CloudFlareSetting struct { 30 | ID string 31 | Value interface{} 32 | ModifiedOn string `json:"modified_on"` 33 | Editable bool 34 | } 35 | 36 | type CloudFlareSettings []CloudFlareSetting 37 | 38 | func (c CloudFlareSettings) ConfigItems() ConfigItems { 39 | config := ConfigItems{} 40 | for _, setting := range c { 41 | config[setting.ID] = setting.Value 42 | } 43 | 44 | return config 45 | } 46 | 47 | type CloudFlareRequestItem struct { 48 | Value interface{} `json:"value"` 49 | } 50 | 51 | type CloudFlare struct { 52 | Client *http.Client 53 | Query *CloudFlareQuery 54 | log *log.Logger 55 | } 56 | 57 | func (c *CloudFlare) Set(zone, id string, val interface{}) error { 58 | body, err := json.Marshal(&CloudFlareRequestItem{Value: val}) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | req, err := c.Query.NewRequestBody("PATCH", 64 | fmt.Sprintf("/zones/%s/settings/%s", zone, id), 65 | bytes.NewBuffer(body)) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | _, err = c.MakeRequest(req) 71 | 72 | return err 73 | } 74 | 75 | func (c *CloudFlare) Settings(zoneID string) (CloudFlareSettings, error) { 76 | var settings CloudFlareSettings 77 | 78 | req, err := c.Query.NewRequest("GET", fmt.Sprintf("/zones/%s/settings", zoneID)) 79 | if err != nil { 80 | return settings, err 81 | } 82 | 83 | response, err := c.MakeRequest(req) 84 | if err != nil { 85 | return settings, err 86 | } 87 | 88 | err = json.Unmarshal(response.Result, &settings) 89 | 90 | return settings, err 91 | } 92 | 93 | func (c *CloudFlare) Update(zone string, config ConfigItemsForUpdate, logOnly bool) error { 94 | var action string 95 | if logOnly { 96 | action = "Would have changed" 97 | } else { 98 | action = "Changing" 99 | } 100 | 101 | for key, vals := range config { 102 | c.log.Printf("%s setting %q from %#v to %#v", action, key, vals.Current, vals.Expected) 103 | 104 | if !logOnly { 105 | if err := c.Set(zone, key, vals.Expected); err != nil { 106 | return err 107 | } 108 | } 109 | } 110 | 111 | return nil 112 | } 113 | 114 | func (c *CloudFlare) Zones() ([]CloudFlareZoneItem, error) { 115 | req, err := c.Query.NewRequest("GET", "/zones") 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | response, err := c.MakeRequest(req) 121 | if err != nil { 122 | return nil, err 123 | } 124 | 125 | var zones []CloudFlareZoneItem 126 | err = json.Unmarshal(response.Result, &zones) 127 | 128 | return zones, err 129 | } 130 | 131 | func (c *CloudFlare) MakeRequest(request *http.Request) (*CloudFlareResponse, error) { 132 | resp, err := c.Client.Do(request) 133 | if err != nil { 134 | return nil, err 135 | } 136 | defer resp.Body.Close() 137 | 138 | body, err := ioutil.ReadAll(resp.Body) 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | if resp.StatusCode != http.StatusOK { 144 | return nil, fmt.Errorf("Didn't get 200 response, body: %s", body) 145 | } 146 | 147 | var response CloudFlareResponse 148 | err = json.Unmarshal(body, &response) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | if !response.Success || len(response.Errors) > 0 { 154 | return nil, fmt.Errorf("Response body indicated failure, response: %#v", response) 155 | } 156 | 157 | return &response, err 158 | } 159 | 160 | func NewCloudFlare(query *CloudFlareQuery, logger *log.Logger) *CloudFlare { 161 | return &CloudFlare{ 162 | Client: &http.Client{}, 163 | Query: query, 164 | log: logger, 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /cloudflare_query.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | type CloudFlareQuery struct { 10 | RootURL string 11 | AuthEmail string 12 | AuthKey string 13 | } 14 | 15 | func (q *CloudFlareQuery) NewRequest(method, path string) (*http.Request, error) { 16 | return q.NewRequestBody(method, path, nil) 17 | } 18 | 19 | func (q *CloudFlareQuery) NewRequestBody(method, path string, body io.Reader) (*http.Request, error) { 20 | url := fmt.Sprintf("%s%s", q.RootURL, path) 21 | 22 | request, err := http.NewRequest(method, url, body) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | request.Header.Set("Content-Type", "application/json") 28 | request.Header.Set("X-Auth-Email", q.AuthEmail) 29 | request.Header.Set("X-Auth-Key", q.AuthKey) 30 | 31 | return request, nil 32 | } 33 | -------------------------------------------------------------------------------- /cloudflare_query_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | . "github.com/alphagov/cloudflare-configure" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | 9 | "io/ioutil" 10 | "net/http" 11 | "strings" 12 | ) 13 | 14 | const ( 15 | headerEmail = "X-Auth-Email" 16 | headerKey = "X-Auth-Key" 17 | authEmail = "user@example.com" 18 | authKey = "abc123" 19 | ) 20 | 21 | var _ = Describe("CloudFlareQuery", func() { 22 | var ( 23 | err error 24 | query CloudFlareQuery 25 | req *http.Request 26 | ) 27 | 28 | BeforeEach(func() { 29 | query = CloudFlareQuery{ 30 | RootURL: "https://example.com/api", 31 | AuthEmail: authEmail, 32 | AuthKey: authKey, 33 | } 34 | }) 35 | 36 | Describe("MakeRequest", func() { 37 | BeforeEach(func() { 38 | req, err = query.NewRequest("GET", "/zones") 39 | }) 40 | 41 | It("should return request and no errors", func() { 42 | Expect(req).ToNot(BeNil()) 43 | Expect(err).To(BeNil()) 44 | }) 45 | 46 | It("should set method and path", func() { 47 | Expect(req.Method).To(Equal("GET")) 48 | Expect(req.URL.String()).To(Equal("https://example.com/api/zones")) 49 | }) 50 | 51 | It("should not set request body", func() { 52 | Expect(req.Body).To(BeNil()) 53 | }) 54 | 55 | It("should set Content-Type to JSON", func() { 56 | Expect(req.Header.Get("Content-Type")).To(Equal("application/json")) 57 | }) 58 | 59 | It("should set authentication email and key header", func() { 60 | Expect(req.Header.Get(headerEmail)).To(Equal(authEmail)) 61 | Expect(req.Header.Get(headerKey)).To(Equal(authKey)) 62 | }) 63 | }) 64 | 65 | Describe("MakeRequestBody", func() { 66 | const body = `{"foo": "bar"}` 67 | 68 | BeforeEach(func() { 69 | req, err = query.NewRequestBody( 70 | "PATCH", "/settings", strings.NewReader(body), 71 | ) 72 | }) 73 | 74 | It("should return request and no errors", func() { 75 | Expect(req).ToNot(BeNil()) 76 | Expect(err).To(BeNil()) 77 | }) 78 | 79 | It("should set method and URL", func() { 80 | Expect(req.Method).To(Equal("PATCH")) 81 | Expect(req.URL.String()).To(Equal("https://example.com/api/settings")) 82 | }) 83 | 84 | It("should set request body", func() { 85 | buf, err := ioutil.ReadAll(req.Body) 86 | defer req.Body.Close() 87 | 88 | Expect(err).To(BeNil()) 89 | Expect(buf).To(MatchJSON(body)) 90 | }) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /cloudflare_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | . "github.com/alphagov/cloudflare-configure" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | "github.com/onsi/gomega/gbytes" 9 | "github.com/onsi/gomega/ghttp" 10 | 11 | "fmt" 12 | "log" 13 | "net/http" 14 | ) 15 | 16 | var _ = Describe("CloudFlare", func() { 17 | var ( 18 | server *ghttp.Server 19 | query *CloudFlareQuery 20 | logbuf *gbytes.Buffer 21 | cloudFlare *CloudFlare 22 | ) 23 | 24 | BeforeEach(func() { 25 | server = ghttp.NewServer() 26 | query = &CloudFlareQuery{RootURL: server.URL()} 27 | 28 | logbuf = gbytes.NewBuffer() 29 | logger := log.New(logbuf, "", 0) 30 | 31 | cloudFlare = NewCloudFlare(query, logger) 32 | }) 33 | 34 | AfterEach(func() { 35 | server.Close() 36 | }) 37 | 38 | Describe("CloudFlareSettings", func() { 39 | Describe("ConfigItems()", func() { 40 | It("should return ConfigItems", func() { 41 | settings := CloudFlareSettings{ 42 | CloudFlareSetting{ 43 | ID: "always_online", 44 | Value: "off", 45 | ModifiedOn: "2014-07-09T11:50:56.595672Z", 46 | Editable: true, 47 | }, 48 | CloudFlareSetting{ 49 | ID: "browser_cache_ttl", 50 | Value: 14400, 51 | ModifiedOn: "2014-07-09T11:50:56.595672Z", 52 | Editable: true, 53 | }, 54 | } 55 | 56 | Expect(settings.ConfigItems()).To(Equal(ConfigItems{ 57 | "always_online": "off", 58 | "browser_cache_ttl": 14400, 59 | })) 60 | }) 61 | }) 62 | }) 63 | 64 | Describe("Zones()", func() { 65 | BeforeEach(func() { 66 | server.AppendHandlers( 67 | ghttp.CombineHandlers( 68 | ghttp.VerifyRequest("GET", "/zones"), 69 | ghttp.RespondWith(http.StatusOK, `{ 70 | "errors": [], 71 | "messages": [], 72 | "result": [ 73 | {"id": "123", "name": "foo"}, 74 | {"id": "456", "name": "bar"} 75 | ], 76 | "success": true 77 | }`), 78 | ), 79 | ) 80 | }) 81 | 82 | It("should return two CloudFlareZoneItems", func() { 83 | zones, err := cloudFlare.Zones() 84 | 85 | Expect(zones).To(Equal([]CloudFlareZoneItem{ 86 | { 87 | ID: "123", 88 | Name: "foo", 89 | }, 90 | { 91 | ID: "456", 92 | Name: "bar", 93 | }, 94 | })) 95 | Expect(err).To(BeNil()) 96 | }) 97 | }) 98 | 99 | Describe("MakeRequest()", func() { 100 | var req *http.Request 101 | 102 | BeforeEach(func() { 103 | req, _ = query.NewRequest("GET", "/something") 104 | }) 105 | 106 | Context("200, success: true, errors: []", func() { 107 | BeforeEach(func() { 108 | server.AppendHandlers( 109 | ghttp.CombineHandlers( 110 | ghttp.VerifyRequest("GET", "/something"), 111 | ghttp.RespondWith(http.StatusOK, `{ 112 | "errors": [], 113 | "messages": [], 114 | "result": [], 115 | "success": true 116 | }`), 117 | ), 118 | ) 119 | }) 120 | 121 | It("should not return error", func() { 122 | resp, err := cloudFlare.MakeRequest(req) 123 | 124 | Expect(resp).ToNot(BeNil()) 125 | Expect(err).To(BeNil()) 126 | }) 127 | }) 128 | 129 | Context("200, success: false, errors: []", func() { 130 | BeforeEach(func() { 131 | server.AppendHandlers( 132 | ghttp.CombineHandlers( 133 | ghttp.VerifyRequest("GET", "/something"), 134 | ghttp.RespondWith(http.StatusOK, `{ 135 | "errors": [], 136 | "messages": [], 137 | "result": [], 138 | "success": false 139 | }`), 140 | ), 141 | ) 142 | }) 143 | 144 | It("should return error", func() { 145 | resp, err := cloudFlare.MakeRequest(req) 146 | 147 | Expect(resp).To(BeNil()) 148 | Expect(err).To(MatchError(`Response body indicated failure, response: main.CloudFlareResponse{Success:false, Errors:[]main.CloudFlareError{}, Messages:[]string{}, Result:json.RawMessage{0x5b, 0x5d}}`)) 149 | }) 150 | }) 151 | 152 | Context("200, success: true, errors: [code: 1000, message: something bad]", func() { 153 | BeforeEach(func() { 154 | server.AppendHandlers( 155 | ghttp.CombineHandlers( 156 | ghttp.VerifyRequest("GET", "/something"), 157 | ghttp.RespondWith(http.StatusOK, `{ 158 | "errors": [{ 159 | "code": 1000, 160 | "message": "something bad" 161 | }], 162 | "messages": [], 163 | "result": [], 164 | "success": true 165 | }`), 166 | ), 167 | ) 168 | }) 169 | 170 | It("should return error", func() { 171 | resp, err := cloudFlare.MakeRequest(req) 172 | 173 | Expect(resp).To(BeNil()) 174 | Expect(err).To(MatchError(`Response body indicated failure, response: main.CloudFlareResponse{Success:true, Errors:[]main.CloudFlareError{main.CloudFlareError{Code:1000, Message:"something bad"}}, Messages:[]string{}, Result:json.RawMessage{0x5b, 0x5d}}`)) 175 | }) 176 | }) 177 | 178 | Context("200, non-JSON body", func() { 179 | BeforeEach(func() { 180 | server.AppendHandlers( 181 | ghttp.CombineHandlers( 182 | ghttp.VerifyRequest("GET", "/something"), 183 | ghttp.RespondWith(http.StatusOK, "something invalid"), 184 | ), 185 | ) 186 | }) 187 | 188 | It("should return error", func() { 189 | resp, err := cloudFlare.MakeRequest(req) 190 | 191 | Expect(resp).To(BeNil()) 192 | Expect(err).To(MatchError("invalid character 's' looking for beginning of value")) 193 | }) 194 | }) 195 | 196 | Context("500, non-JSON body", func() { 197 | BeforeEach(func() { 198 | server.AppendHandlers( 199 | ghttp.CombineHandlers( 200 | ghttp.VerifyRequest("GET", "/something"), 201 | ghttp.RespondWith(http.StatusServiceUnavailable, "something invalid"), 202 | ), 203 | ) 204 | }) 205 | 206 | It("should return error", func() { 207 | resp, err := cloudFlare.MakeRequest(req) 208 | 209 | Expect(resp).To(BeNil()) 210 | Expect(err).To(MatchError("Didn't get 200 response, body: something invalid")) 211 | }) 212 | }) 213 | }) 214 | 215 | Describe("Settings()", func() { 216 | zoneID := "123" 217 | 218 | BeforeEach(func() { 219 | server.AppendHandlers( 220 | ghttp.CombineHandlers( 221 | ghttp.VerifyRequest("GET", 222 | fmt.Sprintf("/zones/%s/settings", zoneID), 223 | ), 224 | ghttp.RespondWith(http.StatusOK, `{ 225 | "errors": [], 226 | "messages": [], 227 | "result": [ 228 | { 229 | "id": "always_online", 230 | "value": "off", 231 | "modified_on": "2014-07-09T11:50:56.595672Z", 232 | "editable": true 233 | }, 234 | { 235 | "id": "browser_cache_ttl", 236 | "value": 14400, 237 | "modified_on": "2014-07-09T11:50:56.595672Z", 238 | "editable": true 239 | } 240 | ], 241 | "success": true 242 | }`), 243 | ), 244 | ) 245 | }) 246 | 247 | It("should return two CloudFlareSettings", func() { 248 | settings, err := cloudFlare.Settings(zoneID) 249 | 250 | Expect(settings).To(Equal(CloudFlareSettings{ 251 | CloudFlareSetting{ 252 | ID: "always_online", 253 | Value: "off", 254 | ModifiedOn: "2014-07-09T11:50:56.595672Z", 255 | Editable: true, 256 | }, 257 | CloudFlareSetting{ 258 | ID: "browser_cache_ttl", 259 | Value: float64(14400), 260 | ModifiedOn: "2014-07-09T11:50:56.595672Z", 261 | Editable: true, 262 | }, 263 | })) 264 | Expect(err).To(BeNil()) 265 | }) 266 | }) 267 | 268 | Describe("Set()", func() { 269 | zoneID := "123" 270 | settingKey := "always_online" 271 | settingVal := "off" 272 | 273 | BeforeEach(func() { 274 | server.AppendHandlers( 275 | ghttp.CombineHandlers( 276 | ghttp.VerifyRequest("PATCH", 277 | fmt.Sprintf("/zones/%s/settings/%s", zoneID, settingKey), 278 | ), 279 | ghttp.VerifyJSON(fmt.Sprintf(`{"value": "%s"}`, settingVal)), 280 | 281 | ghttp.RespondWith(http.StatusOK, `{ 282 | "errors": [], 283 | "messages": [], 284 | "result": { 285 | "id": "always_online", 286 | "value": "off", 287 | "modified_on": "2014-07-09T11:50:56.595672Z", 288 | "editable": true 289 | }, 290 | "success": true 291 | }`), 292 | ), 293 | ) 294 | }) 295 | 296 | It("should set the value with no errors", func() { 297 | Expect(cloudFlare.Set(zoneID, settingKey, settingVal)).To(BeNil()) 298 | }) 299 | }) 300 | 301 | Describe("Update()", func() { 302 | zoneID := "123" 303 | settingValAlwaysOnline := "on" 304 | settingValBrowserCache := 123 305 | 306 | Context("logOnly false", func() { 307 | logOnly := false 308 | 309 | BeforeEach(func() { 310 | server.RouteToHandler("PATCH", fmt.Sprintf("/zones/%s/settings/always_online", zoneID), 311 | ghttp.CombineHandlers( 312 | ghttp.VerifyJSON(fmt.Sprintf(`{"value": "%s"}`, settingValAlwaysOnline)), 313 | ghttp.RespondWithJSONEncoded(http.StatusOK, CloudFlareResponse{Success: true}), 314 | ), 315 | ) 316 | server.RouteToHandler("PATCH", fmt.Sprintf("/zones/%s/settings/browser_cache_ttl", zoneID), 317 | ghttp.CombineHandlers( 318 | ghttp.VerifyJSON(fmt.Sprintf(`{"value": %d}`, settingValBrowserCache)), 319 | ghttp.RespondWithJSONEncoded(http.StatusOK, CloudFlareResponse{Success: true}), 320 | ), 321 | ) 322 | }) 323 | 324 | It("should set two config items and log progress", func() { 325 | config := ConfigItemsForUpdate{ 326 | "always_online": ConfigItemForUpdate{ 327 | Current: "off", 328 | Expected: settingValAlwaysOnline, 329 | }, 330 | "browser_cache_ttl": ConfigItemForUpdate{ 331 | Current: nil, 332 | Expected: settingValBrowserCache, 333 | }, 334 | } 335 | 336 | err := cloudFlare.Update(zoneID, config, logOnly) 337 | 338 | Expect(server.ReceivedRequests()).To(HaveLen(2)) 339 | Expect(err).To(BeNil()) 340 | 341 | Expect(logbuf).To(gbytes.Say(fmt.Sprintf( 342 | `Changing setting "always_online" from "off" to "%s"`, settingValAlwaysOnline, 343 | ))) 344 | Expect(logbuf).To(gbytes.Say(fmt.Sprintf( 345 | `Changing setting "browser_cache_ttl" from to %d`, settingValBrowserCache, 346 | ))) 347 | }) 348 | }) 349 | 350 | Context("logOnly true", func() { 351 | logOnly := true 352 | 353 | It("should log progress without setting config items", func() { 354 | config := ConfigItemsForUpdate{ 355 | "always_online": ConfigItemForUpdate{ 356 | Current: "off", 357 | Expected: settingValAlwaysOnline, 358 | }, 359 | "browser_cache_ttl": ConfigItemForUpdate{ 360 | Current: nil, 361 | Expected: settingValBrowserCache, 362 | }, 363 | } 364 | 365 | err := cloudFlare.Update(zoneID, config, logOnly) 366 | 367 | Expect(server.ReceivedRequests()).To(HaveLen(0)) 368 | Expect(err).To(BeNil()) 369 | 370 | Eventually(logbuf).Should(gbytes.Say(fmt.Sprintf( 371 | `Would have changed setting "always_online" from "off" to "%s"`, settingValAlwaysOnline, 372 | ))) 373 | Eventually(logbuf).Should(gbytes.Say(fmt.Sprintf( 374 | `Would have changed setting "browser_cache_ttl" from to %d`, settingValBrowserCache, 375 | ))) 376 | }) 377 | }) 378 | 379 | Context("errors when updating many", func() { 380 | BeforeEach(func() { 381 | unrecognisedSettingHandler := ghttp.CombineHandlers( 382 | ghttp.RespondWithJSONEncoded(http.StatusBadRequest, CloudFlareResponse{ 383 | Success: false, 384 | Errors: []CloudFlareError{{ 385 | Code: 1006, 386 | Message: "Unrecognized zone setting name", 387 | }}, 388 | }), 389 | ) 390 | 391 | server.RouteToHandler("PATCH", 392 | fmt.Sprintf("/zones/%s/settings/unicorns", zoneID), 393 | unrecognisedSettingHandler, 394 | ) 395 | server.RouteToHandler("PATCH", 396 | fmt.Sprintf("/zones/%s/settings/devops_team", zoneID), 397 | unrecognisedSettingHandler, 398 | ) 399 | }) 400 | 401 | It("should return an error and not continue when key is not supported by remote", func() { 402 | config := ConfigItemsForUpdate{ 403 | "unicorns": ConfigItemForUpdate{ 404 | Current: nil, 405 | Expected: "mythical", 406 | }, 407 | "devops_team": ConfigItemForUpdate{ 408 | Current: nil, 409 | Expected: "mythical", 410 | }, 411 | } 412 | 413 | Expect(cloudFlare.Update(zoneID, config, false)).ToNot(BeNil()) 414 | Expect(server.ReceivedRequests()).To(HaveLen(1)) 415 | }) 416 | }) 417 | }) 418 | }) 419 | -------------------------------------------------------------------------------- /config_items.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "reflect" 7 | ) 8 | 9 | type ConfigMismatch struct { 10 | Missing ConfigItems 11 | } 12 | 13 | func (c ConfigMismatch) Error() string { 14 | return "Config found that is present in the CDN config but not in the local config" 15 | } 16 | 17 | type ConfigItems map[string]interface{} 18 | 19 | type ConfigItemForUpdate struct { 20 | Current interface{} 21 | Expected interface{} 22 | } 23 | 24 | type ConfigItemsForUpdate map[string]ConfigItemForUpdate 25 | 26 | func CompareConfigItemsForUpdate(current, expected ConfigItems) (ConfigItemsForUpdate, error) { 27 | union := UnionConfigItems(current, expected) 28 | differenceCurrentAndUnion := DifferenceConfigItems(current, union) 29 | differenceExpectedAndUnion := DifferenceConfigItems(expected, union) 30 | 31 | if len(differenceExpectedAndUnion) > len(differenceCurrentAndUnion) { 32 | return nil, ConfigMismatch{Missing: differenceExpectedAndUnion} 33 | } 34 | 35 | update := ConfigItemsForUpdate{} 36 | for key, val := range differenceCurrentAndUnion { 37 | update[key] = ConfigItemForUpdate{ 38 | Current: current[key], 39 | Expected: val, 40 | } 41 | } 42 | 43 | return update, nil 44 | } 45 | 46 | func DifferenceConfigItems(from, to ConfigItems) ConfigItems { 47 | config := ConfigItems{} 48 | 49 | for key, val := range to { 50 | if innerVal, _ := from[key]; !reflect.DeepEqual(val, innerVal) { 51 | config[key] = val 52 | } 53 | } 54 | 55 | return config 56 | } 57 | 58 | func LoadConfigItems(file string) (ConfigItems, error) { 59 | bs, err := ioutil.ReadFile(file) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | var config ConfigItems 65 | err = json.Unmarshal(bs, &config) 66 | 67 | return config, err 68 | } 69 | 70 | func SaveConfigItems(config ConfigItems, file string) error { 71 | bs, err := json.MarshalIndent(config, "", " ") 72 | if err != nil { 73 | return err 74 | } 75 | 76 | err = ioutil.WriteFile(file, bs, 0644) 77 | return err 78 | } 79 | 80 | func UnionConfigItems(first, second ConfigItems) ConfigItems { 81 | config := ConfigItems{} 82 | 83 | for key, val := range first { 84 | config[key] = val 85 | } 86 | 87 | for key, val := range second { 88 | config[key] = val 89 | } 90 | 91 | return config 92 | } 93 | -------------------------------------------------------------------------------- /config_items_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | . "github.com/alphagov/cloudflare-configure" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | 9 | "io/ioutil" 10 | "os" 11 | "path/filepath" 12 | ) 13 | 14 | var _ = Describe("ConfigItems", func() { 15 | Describe("CompareConfigItemsForUpdate()", func() { 16 | settingValAlwaysOnline := "on" 17 | settingValBrowserCache := 123 18 | 19 | It("should return nothing when local and remote are identical", func() { 20 | config, err := CompareConfigItemsForUpdate( 21 | ConfigItems{ 22 | "always_online": settingValAlwaysOnline, 23 | "browser_cache_ttl": settingValBrowserCache, 24 | }, 25 | ConfigItems{ 26 | "always_online": settingValAlwaysOnline, 27 | "browser_cache_ttl": settingValBrowserCache, 28 | }, 29 | ) 30 | 31 | Expect(config).To(Equal(ConfigItemsForUpdate{})) 32 | Expect(err).To(BeNil()) 33 | }) 34 | 35 | It("should return all items in local when remote is empty", func() { 36 | config, err := CompareConfigItemsForUpdate( 37 | ConfigItems{}, 38 | ConfigItems{ 39 | "always_online": settingValAlwaysOnline, 40 | "browser_cache_ttl": settingValBrowserCache, 41 | }, 42 | ) 43 | 44 | Expect(config).To(Equal(ConfigItemsForUpdate{ 45 | "always_online": ConfigItemForUpdate{ 46 | Current: nil, 47 | Expected: settingValAlwaysOnline, 48 | }, 49 | "browser_cache_ttl": ConfigItemForUpdate{ 50 | Current: nil, 51 | Expected: settingValBrowserCache, 52 | }, 53 | })) 54 | Expect(err).To(BeNil()) 55 | }) 56 | 57 | It("should return one item in local overwriting always_online", func() { 58 | config, err := CompareConfigItemsForUpdate( 59 | ConfigItems{ 60 | "always_online": "off", 61 | "browser_cache_ttl": settingValBrowserCache, 62 | }, 63 | ConfigItems{ 64 | "always_online": settingValAlwaysOnline, 65 | "browser_cache_ttl": settingValBrowserCache, 66 | }, 67 | ) 68 | 69 | Expect(config).To(Equal(ConfigItemsForUpdate{ 70 | "always_online": ConfigItemForUpdate{ 71 | Current: "off", 72 | Expected: settingValAlwaysOnline, 73 | }, 74 | })) 75 | Expect(err).To(BeNil()) 76 | }) 77 | 78 | It("should return a public error when item is missing in local", func() { 79 | config, err := CompareConfigItemsForUpdate( 80 | ConfigItems{ 81 | "always_online": settingValAlwaysOnline, 82 | "browser_cache_ttl": settingValBrowserCache, 83 | }, 84 | ConfigItems{ 85 | "browser_cache_ttl": settingValBrowserCache, 86 | }, 87 | ) 88 | 89 | Expect(config).To(BeNil()) 90 | Expect(err).ToNot(BeNil()) 91 | Expect(err).To(MatchError( 92 | ConfigMismatch{Missing: ConfigItems{"always_online": settingValAlwaysOnline}})) 93 | }) 94 | }) 95 | 96 | Describe("Difference", func() { 97 | It("returns the difference of two ConfigItems objects", func() { 98 | Expect(DifferenceConfigItems( 99 | ConfigItems{ 100 | "always_online": "off", 101 | "browser_check": "off", 102 | }, 103 | ConfigItems{ 104 | "always_online": "on", 105 | "browser_check": "off", 106 | "browser_cache_ttl": 14400, 107 | }, 108 | )).To(Equal( 109 | ConfigItems{ 110 | "always_online": "on", 111 | "browser_cache_ttl": 14400, 112 | }, 113 | )) 114 | }) 115 | }) 116 | 117 | Describe("Union", func() { 118 | It("merges two ConfigItems objects overwriting values of the latter with the former", func() { 119 | Expect(UnionConfigItems( 120 | ConfigItems{ 121 | "always_online": "off", 122 | "browser_check": "off", 123 | }, 124 | ConfigItems{ 125 | "always_online": "on", 126 | "browser_cache_ttl": 14400, 127 | }, 128 | )).To(Equal( 129 | ConfigItems{ 130 | "always_online": "on", 131 | "browser_check": "off", 132 | "browser_cache_ttl": 14400, 133 | }, 134 | )) 135 | }) 136 | }) 137 | 138 | Describe("file handling", func() { 139 | var ( 140 | tempDir string 141 | tempFile string 142 | ) 143 | 144 | BeforeEach(func() { 145 | var err error 146 | tempDir, err = ioutil.TempDir("", "cloudflare-configure") 147 | Expect(err).To(BeNil()) 148 | 149 | tempFile = filepath.Join(tempDir, "cloudflare-configure.json") 150 | }) 151 | 152 | AfterEach(func() { 153 | err := os.RemoveAll(tempDir) 154 | Expect(err).To(BeNil()) 155 | }) 156 | 157 | configObject := ConfigItems{ 158 | "always_online": "off", 159 | "browswer_cache_ttl": float64(14400), 160 | "mobile_redirect": map[string]interface{}{ 161 | "mobile_subdomain": nil, 162 | "status": "off", 163 | "strip_uri": false, 164 | }, 165 | } 166 | configJSON := `{ 167 | "always_online": "off", 168 | "browswer_cache_ttl": 14400, 169 | "mobile_redirect": { 170 | "mobile_subdomain": null, 171 | "status": "off", 172 | "strip_uri": false 173 | } 174 | }` 175 | 176 | Describe("SaveConfigItems()", func() { 177 | It("should save ConfigItems to a file as pretty-formatted JSON", func() { 178 | err := SaveConfigItems(configObject, tempFile) 179 | Expect(err).To(BeNil()) 180 | 181 | out, err := ioutil.ReadFile(tempFile) 182 | Expect(out).To(MatchJSON(configJSON)) 183 | Expect(err).To(BeNil()) 184 | }) 185 | }) 186 | 187 | Describe("LoadConfigItems()", func() { 188 | It("should read ConfigItems from a JSON file", func() { 189 | err := ioutil.WriteFile(tempFile, []byte(configJSON), 0644) 190 | Expect(err).To(BeNil()) 191 | 192 | out, err := LoadConfigItems(tempFile) 193 | Expect(out).To(Equal(configObject)) 194 | Expect(err).To(BeNil()) 195 | }) 196 | }) 197 | }) 198 | }) 199 | -------------------------------------------------------------------------------- /jenkins.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | 4 | # Isolated GOPATH for Jenkins. 5 | export GOPATH="$(pwd)/gopath" 6 | export PATH="${GOPATH}/bin:${PATH}" 7 | 8 | ORG="github.com/alphagov" 9 | REPO="${ORG}/cloudflare-configure" 10 | mkdir -p ${GOPATH}/src/${ORG} 11 | [ -h ${GOPATH}/src/${REPO} ] || ln -s ../../../.. ${GOPATH}/src/${REPO} 12 | 13 | make 14 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "gopkg.in/jwaldrip/odin.v1/cli" 9 | ) 10 | 11 | const flagRequiredDefault = "REQUIRED" 12 | 13 | var app = cli.New(Version, "CloudFlare Configure", exitWithUsage) 14 | 15 | func init() { 16 | app.DefineStringFlag("email", flagRequiredDefault, "Authentication email address") 17 | app.DefineStringFlag("key", flagRequiredDefault, "Authentication key") 18 | 19 | zones := app.DefineSubCommand("zones", "List available zones by name and ID", zones) 20 | zones.InheritFlags("email", "key") 21 | 22 | download := app.DefineSubCommand("download", "Download configuration to file", download) 23 | download.InheritFlags("email", "key") 24 | download.DefineParams("zone_id", "file") 25 | 26 | upload := app.DefineSubCommand("upload", "Upload configuration from file", upload) 27 | upload.InheritFlags("email", "key") 28 | upload.DefineParams("zone_id", "file") 29 | upload.DefineBoolFlag("dry-run", false, "Log changes without actioning them") 30 | } 31 | 32 | func main() { 33 | app.Start() 34 | } 35 | 36 | func setup(cmd cli.Command) *CloudFlare { 37 | query := &CloudFlareQuery{ 38 | AuthEmail: getRequiredFlag(cmd, "email"), 39 | AuthKey: getRequiredFlag(cmd, "key"), 40 | RootURL: "https://api.cloudflare.com/v4", 41 | } 42 | logger := log.New(os.Stdout, "", log.LstdFlags) 43 | 44 | return NewCloudFlare(query, logger) 45 | } 46 | 47 | func getRequiredFlag(cmd cli.Command, name string) string { 48 | val := cmd.Flag(name).String() 49 | if val == flagRequiredDefault { 50 | fmt.Print("missing flag: ", name, "\n\n") 51 | exitWithUsage(cmd) 52 | } 53 | 54 | return val 55 | } 56 | 57 | func exitWithUsage(cmd cli.Command) { 58 | cmd.Usage() 59 | os.Exit(2) 60 | } 61 | 62 | func zones(cmd cli.Command) { 63 | cloudflare := setup(cmd) 64 | zones, err := cloudflare.Zones() 65 | if err != nil { 66 | log.Fatalln(err) 67 | } 68 | 69 | for _, zone := range zones { 70 | fmt.Println(zone.ID, "\t", zone.Name) 71 | } 72 | } 73 | 74 | func download(cmd cli.Command) { 75 | cloudflare := setup(cmd) 76 | settings, err := cloudflare.Settings(cmd.Param("zone_id").String()) 77 | if err != nil { 78 | log.Fatalln(err) 79 | } 80 | 81 | file := cmd.Param("file").String() 82 | log.Println("Saving config to:", file) 83 | 84 | err = SaveConfigItems(settings.ConfigItems(), file) 85 | if err != nil { 86 | log.Fatalln(err) 87 | } 88 | } 89 | 90 | func upload(cmd cli.Command) { 91 | cloudflare := setup(cmd) 92 | zone := cmd.Param("zone_id").String() 93 | 94 | settings, err := cloudflare.Settings(zone) 95 | if err != nil { 96 | log.Fatalln(err) 97 | } 98 | 99 | configActual := settings.ConfigItems() 100 | configDesired, err := LoadConfigItems(cmd.Param("file").String()) 101 | if err != nil { 102 | log.Fatalln(err) 103 | } 104 | 105 | configUpdate, err := CompareConfigItemsForUpdate(configActual, configDesired) 106 | if err != nil { 107 | log.Fatalln(err) 108 | } 109 | 110 | logOnly := (cmd.Flag("dry-run").Get() == true) 111 | err = cloudflare.Update(zone, configUpdate, logOnly) 112 | if err != nil { 113 | log.Fatalln(err) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /main_suite_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestCloudFlareConfigure(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "CloudFlareConfigure Suite") 13 | } 14 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const Version = "0.1.0" 4 | --------------------------------------------------------------------------------