├── AUTHORS ├── webapp ├── .gitignore ├── static │ └── script.js ├── .ebc-vars ├── .ebc-bundle ├── .ebextensions │ └── server.config └── server.go ├── .gitignore ├── elasticbeanstalk ├── app_test.go ├── client_test.go ├── app.go ├── elasticbeanstalk_test.go ├── client.go ├── env.go └── env_test.go ├── LICENSE ├── README.md └── cmd └── ebc └── ebc.go /AUTHORS: -------------------------------------------------------------------------------- 1 | Quinn Slack -------------------------------------------------------------------------------- /webapp/.gitignore: -------------------------------------------------------------------------------- 1 | .elasticbeanstalk/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .elasticbeanstalk/ 2 | eb-bundle.zip 3 | 4 | -------------------------------------------------------------------------------- /webapp/static/script.js: -------------------------------------------------------------------------------- 1 | console.log('Hello from JavaScript!') 2 | -------------------------------------------------------------------------------- /webapp/.ebc-vars: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | EBC_ENV=$1 4 | EBC_APP=$2 5 | 6 | echo EBC_ENV=$1 7 | echo EBC_APP=$2 8 | echo EBC_SET_BY_VARS_SCRIPT=i-was-set-from-a-script 9 | 10 | -------------------------------------------------------------------------------- /webapp/.ebc-bundle: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | outdir="$1" 3 | 4 | echo BUNDLE SCRIPT: $outdir 5 | 6 | go build -o "$outdir"/server server.go && \ 7 | cp -R .ebextensions static "$outdir" && \ 8 | git rev-parse HEAD > "$outdir"/.git-commit-id && \ 9 | git rev-parse --abbrev-ref HEAD > "$outdir"/.git-branch && \ 10 | 11 | echo BUNDLE SCRIPT: done 12 | -------------------------------------------------------------------------------- /webapp/.ebextensions/server.config: -------------------------------------------------------------------------------- 1 | option_settings: 2 | - namespace: aws:elasticbeanstalk:container:nodejs 3 | option_name: ProxyServer 4 | value: nginx 5 | - namespace: aws:elasticbeanstalk:container:nodejs 6 | option_name: NodeCommand 7 | value: ./server 8 | - namespace: aws:elasticbeanstalk:container:nodejs:staticfiles 9 | option_name: /static 10 | value: /static -------------------------------------------------------------------------------- /elasticbeanstalk/app_test.go: -------------------------------------------------------------------------------- 1 | package elasticbeanstalk 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestCreateApplicationVersion(t *testing.T) { 9 | setup() 10 | defer teardown() 11 | 12 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 13 | testMethod(t, r, "POST") 14 | }) 15 | 16 | err := client.CreateApplicationVersion(&CreateApplicationVersionParams{}) 17 | if err != nil { 18 | t.Errorf("CreateApplicationVersion returned error: %v", err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /elasticbeanstalk/client_test.go: -------------------------------------------------------------------------------- 1 | package elasticbeanstalk 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestTime_UnmarshalJSON(t *testing.T) { 9 | jsonStr := "1.415215656E9" 10 | var tm Time 11 | if err := json.Unmarshal([]byte(jsonStr), &tm); err != nil { 12 | t.Error(err) 13 | } 14 | 15 | want := mustParseTime(t, "2014-11-05T19:27:36Z").UTC() 16 | if !tm.Equal(want) { 17 | t.Errorf("got %v, want %v", tm, want) 18 | } 19 | } 20 | 21 | func TestTime_MarshalJSON(t *testing.T) { 22 | tm := mustParseTime(t, "2014-11-05T19:27:36Z") 23 | jsonStr, err := json.Marshal(tm) 24 | if err != nil { 25 | t.Error(err) 26 | } 27 | 28 | want := "1.415215656E9" 29 | if string(jsonStr) != want { 30 | t.Errorf("got %s, want %v", jsonStr, want) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /elasticbeanstalk/app.go: -------------------------------------------------------------------------------- 1 | package elasticbeanstalk 2 | 3 | import ( 4 | "github.com/google/go-querystring/query" 5 | ) 6 | 7 | type CreateApplicationVersionParams struct { 8 | ApplicationName string 9 | VersionLabel string 10 | Description string 11 | SourceBundleS3Bucket string `url:"SourceBundle.S3Bucket"` 12 | SourceBundleS3Key string `url:"SourceBundle.S3Key"` 13 | } 14 | 15 | func (c *Client) CreateApplicationVersion(params *CreateApplicationVersionParams) error { 16 | // AWS wants "Description=", not just "Description", if empty, so force it 17 | // to be non-empty TODO(sqs):try omitempty 18 | if params.Description == "" { 19 | params.Description = "_" 20 | } 21 | v, err := query.Values(params) 22 | if err != nil { 23 | return err 24 | } 25 | return c.Do("POST", "CreateApplicationVersion", v, nil) 26 | } 27 | -------------------------------------------------------------------------------- /webapp/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | func port() string { 15 | if port := os.Getenv("PORT"); port != "" { 16 | return port 17 | } 18 | return "8888" 19 | } 20 | 21 | var bindAddr = flag.String("http", ":"+port(), "http listen address") 22 | var gitCommitID, gitBranch string 23 | 24 | func init() { 25 | data, err := ioutil.ReadFile("./.git-commit-id") 26 | if err != nil { 27 | gitCommitID = fmt.Sprintf("error: %s", err) 28 | } 29 | gitCommitID = strings.TrimSpace(string(data)) 30 | 31 | data, err = ioutil.ReadFile("./.git-branch") 32 | if err != nil { 33 | gitBranch = fmt.Sprintf("error: %s", err) 34 | } 35 | gitBranch = strings.TrimSpace(string(data)) 36 | } 37 | 38 | func main() { 39 | flag.Parse() 40 | 41 | http.Handle("/", http.HandlerFunc(hello)) 42 | 43 | log.Printf("Listening on %s...", *bindAddr) 44 | err := http.ListenAndServe(*bindAddr, nil) 45 | if err != nil { 46 | log.Fatal("ListenAndServe:", err) 47 | } 48 | } 49 | 50 | var t0 = time.Now() 51 | var hostname string 52 | 53 | func init() { 54 | var err error 55 | hostname, err = os.Hostname() 56 | if err != nil { 57 | log.Fatal("Hostname:", err) 58 | } 59 | } 60 | 61 | func hello(w http.ResponseWriter, r *http.Request) { 62 | fmt.Fprintln(w, "Hello from Go!") 63 | fmt.Fprintln(w) 64 | fmt.Fprintln(w, "Git commit ID:", gitCommitID) 65 | fmt.Fprintln(w, "Git branch:", gitBranch) 66 | fmt.Fprintln(w, "Uptime:", time.Since(t0)) 67 | fmt.Fprintln(w) 68 | fmt.Fprintln(w, "`hostname`:", hostname) 69 | fmt.Fprintln(w) 70 | fmt.Fprintln(w, "EBC_* env vars:") 71 | for _, v := range os.Environ() { 72 | if strings.HasPrefix(v, "EBC_") { 73 | fmt.Fprintf(w, " %s\n", v) 74 | } 75 | } 76 | fmt.Fprintln(w) 77 | fmt.Fprintln(w, "Request headers:") 78 | for k, vs := range r.Header { 79 | for _, v := range vs { 80 | fmt.Fprintf(w, " %s: %s\n", k, v) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /elasticbeanstalk/elasticbeanstalk_test.go: -------------------------------------------------------------------------------- 1 | package elasticbeanstalk 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | // Testing scheme adapted from go-github. 14 | 15 | var ( 16 | // mux i the HTTP request multiplexer used with the test server. 17 | mux *http.ServeMux 18 | 19 | // client is the GitHub client being tested. 20 | client *Client 21 | 22 | // server is a test HTTP server used to provide mock API responses. 23 | server *httptest.Server 24 | ) 25 | 26 | // setup sets up a test HTTP server along with an elasticbeanstalk.Client that is 27 | // configured to talk to that test server. Tests should register handlers on 28 | // mux which provide mock responses for the API method being tested. 29 | func setup() { 30 | // test server 31 | mux = http.NewServeMux() 32 | server = httptest.NewServer(mux) 33 | 34 | // elasticbeanstalk client configured to use test server 35 | client = NewClient(nil) 36 | client.BaseURL, _ = url.Parse(server.URL) 37 | } 38 | 39 | // teardown closes the test HTTP server. 40 | func teardown() { 41 | server.Close() 42 | } 43 | 44 | func writeJSON(w http.ResponseWriter, jsonStr string) { 45 | w.Header().Set("content-type", "application/json; charset=utf-8") 46 | io.WriteString(w, jsonStr) 47 | } 48 | 49 | func testMethod(t *testing.T, r *http.Request, want string) { 50 | if want != r.Method { 51 | t.Errorf("Request method = %v, want %v", r.Method, want) 52 | } 53 | } 54 | 55 | func mustParseTime(t *testing.T, timeStr string) Time { 56 | tm, err := time.Parse(time.RFC3339Nano, timeStr) 57 | if err != nil { 58 | t.Fatal("time.Parse(time.RFC3339Nano, %q) returned error: %v", timeStr, err) 59 | } 60 | tm = tm.Round(time.Millisecond) 61 | return Time{tm} 62 | } 63 | 64 | func asJSON(t *testing.T, v interface{}) string { 65 | b, err := json.MarshalIndent(v, "", " ") 66 | if err != nil { 67 | t.Fatal(t) 68 | } 69 | return string(b) 70 | } 71 | 72 | func normTime(t *Time) { 73 | *t = Time{t.Time.UTC().Round(time.Second)} 74 | } 75 | -------------------------------------------------------------------------------- /elasticbeanstalk/client.go: -------------------------------------------------------------------------------- 1 | package elasticbeanstalk 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "time" 11 | 12 | "github.com/crowdmob/goamz/aws" 13 | ) 14 | 15 | type Client struct { 16 | BaseURL *url.URL 17 | Auth aws.Auth 18 | Region aws.Region 19 | httpClient *http.Client 20 | } 21 | 22 | func NewClient(httpClient *http.Client) *Client { 23 | return &Client{httpClient: httpClient} 24 | } 25 | 26 | func (c *Client) Do(method string, operation string, params url.Values, respData interface{}) error { 27 | url := c.BaseURL.ResolveReference(&url.URL{RawQuery: fmt.Sprintf("Operation=%s&%s", operation, params.Encode())}) 28 | r, err := http.NewRequest(method, url.String(), nil) 29 | if err != nil { 30 | return err 31 | } 32 | r.Header.Set("accept", "application/json") 33 | r.Header.Set("X-Amz-Date", time.Now().UTC().Format(aws.ISO8601BasicFormat)) 34 | signer := aws.NewV4Signer(c.Auth, "elasticbeanstalk", c.Region) 35 | signer.Sign(r) 36 | 37 | httpClient := c.httpClient 38 | if httpClient == nil { 39 | httpClient = http.DefaultClient 40 | } 41 | 42 | resp, err := httpClient.Do(r) 43 | if err != nil { 44 | return err 45 | } 46 | defer resp.Body.Close() 47 | if resp.StatusCode != 200 { 48 | msg, err := ioutil.ReadAll(resp.Body) 49 | if err != nil { 50 | return err 51 | } 52 | return fmt.Errorf("http status code %d (%s): %s", resp.StatusCode, http.StatusText(resp.StatusCode), msg) 53 | } 54 | 55 | if respData != nil { 56 | if err := json.NewDecoder(resp.Body).Decode(respData); err != nil { 57 | return err 58 | } 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // Time is a time.Time whose JSON representation is its floating point 65 | // milliseconds since the epoch. 66 | type Time struct{ time.Time } 67 | 68 | func (t Time) MarshalJSON() ([]byte, error) { 69 | return []byte(strings.Replace(fmt.Sprintf("%.9E", float64(time.Duration(t.UnixNano())/time.Millisecond)), "E+12", "E9", -1)), nil 70 | } 71 | 72 | func (t *Time) UnmarshalJSON(b []byte) error { 73 | var sec float64 74 | if err := json.Unmarshal(b, &sec); err != nil { 75 | return err 76 | } 77 | *t = Time{time.Unix(int64(sec), int64(1000*(sec-float64(int64(sec))))).UTC()} 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 The go-elasticbeanstalk AUTHORS. All rights 2 | reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following disclaimer 12 | in the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Sourcegraph, Inc. nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | ---------------------------------------------------------------------- 31 | 32 | Copyright (c) 2013 The go-github AUTHORS. All rights reserved. 33 | 34 | Redistribution and use in source and binary forms, with or without 35 | modification, are permitted provided that the following conditions are 36 | met: 37 | 38 | * Redistributions of source code must retain the above copyright 39 | notice, this list of conditions and the following disclaimer. 40 | * Redistributions in binary form must reproduce the above 41 | copyright notice, this list of conditions and the following disclaimer 42 | in the documentation and/or other materials provided with the 43 | distribution. 44 | * Neither the name of Google Inc. nor the names of its 45 | contributors may be used to endorse or promote products derived from 46 | this software without specific prior written permission. 47 | 48 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 49 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 50 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 51 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 52 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 53 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 54 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 55 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 56 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 57 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 58 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-elasticbeanstalk 2 | 3 | This repository contains: 4 | 5 | * **ebc**, a command-line tool that simplifies deployment of binaries to [AWS](https://aws.amazon.com) 6 | [Elastic Beanstalk](http://aws.amazon.com/elasticbeanstalk/) 7 | * a simple [elasticbeanstalk API client package](https://sourcegraph.com/github.com/sqs/go-elasticbeanstalk/symbols/go/github.com/sqs/go-elasticbeanstalk/elasticbeanstalk) written in [Go](http://golang.org) 8 | * a sample Go web app that can be deployed to AWS Elastic Beanstalk, along with the necessary configuration to work around the lack of official Go support (see *Implementation details* below) 9 | 10 | [**Documentation on Sourcegraph**](https://sourcegraph.com/github.com/sqs/go-elasticbeanstalk) 11 | 12 | [![elasticbeanstalk](https://sourcegraph.com/github.com/sqs/go-elasticbeanstalk/-/badge.svg)](https://sourcegraph.com/github.com/sqs/go-elasticbeanstalk?badge) 13 | 14 | ## ebc command-line client for AWS Elastic Beanstalk 15 | 16 | ebc makes it easy to build and deploy binary source bundles to AWS Elastic 17 | Beanstalk. You still must use eb to configure and initialize applications and 18 | environments. (If you want to deploy your whole git repository, just use the 19 | official [eb tool](http://aws.amazon.com/code/6752709412171743).) 20 | 21 | * Install ebc: `go get github.com/sqs/go-elasticbeanstalk/cmd/ebc` 22 | * Install the [AWS Elastic Beanstalk eb command-line tool](http://aws.amazon.com/code/6752709412171743) 23 | 24 | ### Walkthrough 25 | 26 | Let's deploy a [simple Go web 27 | app](https://github.com/sqs/go-elasticbeanstalk/blob/master/webapp/server.go) to 28 | [Elastic Beanstalk](http://aws.amazon.com/elasticbeanstalk/). 29 | 30 | First, we need to create the [application and 31 | environment](http://docs.aws.amazon.com/elasticbeanstalk/latest/dg/concepts.components.html). 32 | Follow [AWS EB 33 | documentation](http://docs.aws.amazon.com/elasticbeanstalk/latest/dg/create_deploy_nodejs.sdlc.html) 34 | to get them set up. Once complete, you should be running the sample Node.js app 35 | (it says "Congratulations"). We're going to deploy our Go web app *over* that sample app. 36 | 37 | Now, make sure you've installed `ebc` into your `PATH`. Then, from the top-level 38 | directory of this repository, run the following command: 39 | 40 | ``` 41 | ebc -dir=webapp deploy -h 42 | ``` 43 | 44 | Check the defaults for the `-app`, `-bucket`, `-env`, and `-label` flags. These 45 | values are read from the `.elasticbeanstalk/config` file you set up using `eb 46 | init`. They should refer to the application and environment you created 47 | previously. 48 | 49 | If these values look good, then run: 50 | 51 | ``` 52 | ebc -dir=webapp deploy 53 | ``` 54 | 55 | After a few seconds, you'll see a message like `Deploy initiated (took 5.22s)`. 56 | Now, check the AWS Elastic Beanstalk dashboard to verify that a new application 57 | is being deployed. Once it's complete, browsing to the environment's URL should 58 | display the "Hello from Go!" text, along with some debugging info. You're done! 59 | 60 | #### Deploying from multiple branches 61 | 62 | The eb and ebc tools both support deploying from multiple branches. When you 63 | switch to another branch (with `git checkout`), run `eb branch` to configure the 64 | branch's deployment. The ebc tool reads eb's configuration for a branch, so 65 | there are no extra steps beyond configuring eb correctly. To inspect the 66 | configuration that ebc will use to deploy, run `ebc -dir=DIR deploy -h`. 67 | 68 | The sample `webapp` in this repository displays the git branch used to deploy 69 | it, so you can verify that branch deployment was successful. 70 | 71 | 72 | ## Implementation details 73 | 74 | ### Faking Go support in Elastic Beanstalk 75 | 76 | **NOTE:** Since this section was written, Elastic Beanstalk added Docker support, which lets you run Go apps. If you use Docker, ignore this section. The `ebc` tool is still useful even if you are using Docker (or any other language, for that matter). 77 | 78 | Because Elastic Beanstalk doesn't natively support Go, we have to use a few tricks (in the `webapp/` and `worker/` dirs): 79 | 80 | 1. In `.ebextensions/go.config`, we run a command to install Go on the server, using the 81 | [commands](http://docs.aws.amazon.com/elasticbeanstalk/latest/dg/customize-containers-ec2.html#customize-containers-format-commands) config feature. 82 | 1. In `.ebextensions/server.config`, we trick Elastic Beanstalk into thinking that our Go app is a Node.js app and just tell it to run the command `go run server.go`. 83 | 84 | More information can be found at the [Elastic Beanstalk docs for Node.js 85 | apps](http://docs.aws.amazon.com/elasticbeanstalk/latest/dg/create_deploy_nodejs.sdlc.html). 86 | 87 | ## Contact 88 | 89 | Contact [@sqs](https://twitter.com) with questions. 90 | 91 | -------------------------------------------------------------------------------- /elasticbeanstalk/env.go: -------------------------------------------------------------------------------- 1 | package elasticbeanstalk 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/google/go-querystring/query" 8 | ) 9 | 10 | // DescribeEnvironmentsParams specifies parameters for DescribeEnvironments. 11 | // 12 | // See 13 | // http://docs.aws.amazon.com/elasticbeanstalk/latest/api/API_DescribeEnvironments.html. 14 | type DescribeEnvironmentsParams struct { 15 | ApplicationName string 16 | EnvironmentName string `url:"EnvironmentNames.member.0,omitempty"` 17 | } 18 | 19 | // EnvironmentDescription describes an existing environment. 20 | // 21 | // See 22 | // http://docs.aws.amazon.com/elasticbeanstalk/latest/api/API_EnvironmentDescription.html. 23 | type EnvironmentDescription struct { 24 | ApplicationName string 25 | CNAME string 26 | DateCreated Time 27 | DateUpdated Time 28 | Description string 29 | EndpointURL string 30 | EnvironmentId string 31 | EnvironmentName string 32 | Health string 33 | SolutionStackName string 34 | Status string 35 | TemplateName string 36 | Tier EnvironmentTier 37 | VersionLabel string 38 | 39 | // Omitted fields: Resources 40 | } 41 | 42 | // EnvironmentTier describes the properties of an environment tier. 43 | // 44 | // See 45 | // http://docs.aws.amazon.com/elasticbeanstalk/latest/api/API_EnvironmentTier.html. 46 | type EnvironmentTier struct { 47 | Name string 48 | Type string 49 | Version string 50 | } 51 | 52 | // DescribeEnvironments returns descriptions for matching environments. 53 | // 54 | // See 55 | // http://docs.aws.amazon.com/elasticbeanstalk/latest/api/API_DescribeEnvironments.html. 56 | func (c *Client) DescribeEnvironments(params *DescribeEnvironmentsParams) ([]*EnvironmentDescription, error) { 57 | v, err := query.Values(params) 58 | if err != nil { 59 | return nil, err 60 | } 61 | var o struct { 62 | DescribeEnvironmentsResponse struct { 63 | DescribeEnvironmentsResult struct { 64 | Environments []*EnvironmentDescription 65 | } 66 | } 67 | } 68 | err = c.Do("GET", "DescribeEnvironments", v, &o) 69 | return o.DescribeEnvironmentsResponse.DescribeEnvironmentsResult.Environments, err 70 | } 71 | 72 | // A ConfigurationSettingsDescription describes the settings for a 73 | // configuration. 74 | // 75 | // See 76 | // http://docs.aws.amazon.com/elasticbeanstalk/latest/APIReference/API_ConfigurationSettingsDescription.html. 77 | type ConfigurationSettingsDescription struct { 78 | ApplicationName string 79 | DateCreated Time 80 | DateUpdated Time 81 | DeploymentStatus string 82 | Description string `json:",omitempty"` 83 | EnvironmentName string 84 | OptionSettings ConfigurationOptionSettings 85 | SolutionStackName string 86 | TemplateName string `json:",omitempty"` 87 | } 88 | 89 | // A ConfigurationSettings is a list of 90 | // ConfigurationSettingsDescription that provides easy access to 91 | // combined configuration settings. 92 | type ConfigurationSettings []*ConfigurationSettingsDescription 93 | 94 | // Environ returns a map of all environment variables set in the 95 | // configuration settings. 96 | func (s ConfigurationSettings) Environ() map[string]string { 97 | m := map[string]string{} 98 | for _, csd := range s { 99 | m0 := csd.OptionSettings.Environ() 100 | for k, v := range m0 { 101 | m[k] = v 102 | } 103 | } 104 | return m 105 | } 106 | 107 | // DescribeConfigurationSettingsParams specifies parameters for a 108 | // DescribeConfigurationSettings request. 109 | // 110 | // See 111 | // http://docs.aws.amazon.com/elasticbeanstalk/latest/APIReference/API_DescribeConfigurationSettings.html. 112 | type DescribeConfigurationSettingsParams struct { 113 | ApplicationName string 114 | EnvironmentName string `url:",omitempty"` 115 | TemplateName string `url:",omitempty"` 116 | } 117 | 118 | func (c *Client) DescribeConfigurationSettings(params *DescribeConfigurationSettingsParams) (ConfigurationSettings, error) { 119 | v, err := query.Values(params) 120 | if err != nil { 121 | return nil, err 122 | } 123 | var o struct { 124 | DescribeConfigurationSettingsResponse struct { 125 | DescribeConfigurationSettingsResult struct { 126 | ConfigurationSettings []*ConfigurationSettingsDescription 127 | } 128 | } 129 | } 130 | err = c.Do("GET", "DescribeConfigurationSettings", v, &o) 131 | return o.DescribeConfigurationSettingsResponse.DescribeConfigurationSettingsResult.ConfigurationSettings, err 132 | } 133 | 134 | // A ConfigurationOptionSettings is a list of 135 | // ConfigurationOptionSetting that provides easy access to environment 136 | // variables specified within. 137 | type ConfigurationOptionSettings []ConfigurationOptionSetting 138 | 139 | // Environ returns a map of all environment variables set in the 140 | // option settings. 141 | func (opts ConfigurationOptionSettings) Environ() map[string]string { 142 | m := map[string]string{} 143 | for _, opt := range opts { 144 | if opt.Namespace == envVarNamespace { 145 | m[opt.OptionName] = opt.Value 146 | } 147 | } 148 | return m 149 | } 150 | 151 | type UpdateEnvironmentParams struct { 152 | EnvironmentName string 153 | VersionLabel string `url:",omitempty"` 154 | 155 | OptionSettings ConfigurationOptionSettings `url:"-"` 156 | } 157 | 158 | const envVarNamespace = "aws:elasticbeanstalk:application:environment" 159 | 160 | // AddEnv adds the specified environment variable name and value to 161 | // OptionSettings. 162 | func (p *UpdateEnvironmentParams) AddEnv(name, value string) { 163 | p.OptionSettings = append(p.OptionSettings, ConfigurationOptionSetting{ 164 | Namespace: envVarNamespace, 165 | OptionName: name, 166 | Value: value, 167 | }) 168 | } 169 | 170 | // optionSettingsValues returns a url.Values for the 171 | // (UpdateEnvironmentParams).OptionSettings field entries. Each entry yields 3 172 | // keys whose names are prefixed with `OptionSettings.member.N.`. 173 | func (p *UpdateEnvironmentParams) optionSettingsValues() url.Values { 174 | if len(p.OptionSettings) == 0 { 175 | return nil 176 | } 177 | v := make(url.Values) 178 | for i, s := range p.OptionSettings { 179 | kp := fmt.Sprintf("OptionSettings.member.%d", i+1) 180 | v.Set(kp+".Namespace", s.Namespace) 181 | v.Set(kp+".OptionName", s.OptionName) 182 | v.Set(kp+".Value", s.Value) 183 | } 184 | return v 185 | } 186 | 187 | // ConfigurationOptionSetting is a specification identifying an individual 188 | // configuration option along with its current value. 189 | // 190 | // See 191 | // http://docs.aws.amazon.com/elasticbeanstalk/latest/api/API_ConfigurationOptionSetting.html. 192 | type ConfigurationOptionSetting struct { 193 | Namespace string 194 | OptionName string 195 | Value string 196 | } 197 | 198 | func (c *Client) UpdateEnvironment(params *UpdateEnvironmentParams) error { 199 | v, err := query.Values(params) 200 | if err != nil { 201 | return err 202 | } 203 | 204 | osv := params.optionSettingsValues() 205 | for k, vs := range osv { 206 | v[k] = vs 207 | } 208 | 209 | return c.Do("POST", "UpdateEnvironment", v, nil) 210 | } 211 | -------------------------------------------------------------------------------- /elasticbeanstalk/env_test.go: -------------------------------------------------------------------------------- 1 | package elasticbeanstalk 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "net/url" 7 | "reflect" 8 | "testing" 9 | "time" 10 | 11 | "github.com/kr/pretty" 12 | ) 13 | 14 | func floatTime(t *testing.T, timeStr string) string { 15 | tm := mustParseTime(t, timeStr).Round(time.Millisecond) 16 | b, _ := Time{tm}.MarshalJSON() 17 | return string(b) 18 | } 19 | 20 | func TestDescribeEnvironments(t *testing.T) { 21 | setup() 22 | defer teardown() 23 | 24 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 25 | testMethod(t, r, "GET") 26 | writeJSON(w, ` 27 | { 28 | "DescribeEnvironmentsResponse": {"DescribeEnvironmentsResult": {"Environments": [ 29 | { 30 | "ApplicationName": "app", 31 | "CNAME": "app-env.elasticbeanstalk.com", 32 | "DateCreated": `+floatTime(t, "2014-02-28T00:22:21.474Z")+`, 33 | "DateUpdated": `+floatTime(t, "2014-02-28T00:33:47.684Z")+`, 34 | "EndpointURL": "awseb-e-n-AWSEBLoa-MILTONWOOF-1234567.us-west-2.elb.amazonaws.com", 35 | "EnvironmentId": "e-abcdef1234", 36 | "EnvironmentName": "app-env", 37 | "Health": "Green", 38 | "SolutionStackName": "64bit Amazon Linux 2013.09 running Node.js", 39 | "Status": "Ready", 40 | "Tier": { 41 | "Name": "WebServer", 42 | "Type": "Standard", 43 | "Version": "1.0" 44 | }, 45 | "VersionLabel": "app-123" 46 | } 47 | ] 48 | }}} 49 | `) 50 | }) 51 | 52 | want := []*EnvironmentDescription{ 53 | { 54 | ApplicationName: "app", 55 | CNAME: "app-env.elasticbeanstalk.com", 56 | DateCreated: mustParseTime(t, "2014-02-28T00:22:21.474Z"), 57 | DateUpdated: mustParseTime(t, "2014-02-28T00:33:47.684Z"), 58 | EndpointURL: "awseb-e-n-AWSEBLoa-MILTONWOOF-1234567.us-west-2.elb.amazonaws.com", 59 | EnvironmentId: "e-abcdef1234", 60 | EnvironmentName: "app-env", 61 | Health: "Green", 62 | SolutionStackName: "64bit Amazon Linux 2013.09 running Node.js", 63 | Status: "Ready", 64 | Tier: EnvironmentTier{ 65 | Name: "WebServer", 66 | Type: "Standard", 67 | Version: "1.0", 68 | }, 69 | VersionLabel: "app-123", 70 | }, 71 | } 72 | 73 | envs, err := client.DescribeEnvironments(&DescribeEnvironmentsParams{}) 74 | if err != nil { 75 | t.Errorf("DescribeEnvironments returned error: %v", err) 76 | } 77 | 78 | normTime(&want[0].DateCreated) 79 | normTime(&want[0].DateUpdated) 80 | log.Printf("%s != %s", want[0].DateCreated, envs[0].DateCreated) 81 | if !reflect.DeepEqual(envs, want) { 82 | t.Errorf("DescribeEnvironments returned %+v, want %+v", asJSON(t, envs), asJSON(t, want)) 83 | } 84 | } 85 | 86 | func TestConfigurationSettings_Environ(t *testing.T) { 87 | got := ConfigurationSettings{ 88 | { 89 | OptionSettings: ConfigurationOptionSettings{ 90 | {Namespace: "aws:elasticbeanstalk:application:environment", OptionName: "k1", Value: "v1"}, 91 | }, 92 | }, 93 | { 94 | OptionSettings: ConfigurationOptionSettings{ 95 | {Namespace: "aws:elasticbeanstalk:application:environment", OptionName: "k2", Value: "v2"}, 96 | }, 97 | }, 98 | }.Environ() 99 | want := map[string]string{"k1": "v1", "k2": "v2"} 100 | if !reflect.DeepEqual(got, want) { 101 | t.Errorf("got %v, want %v", got, want) 102 | } 103 | } 104 | 105 | func TestDescribeConfigurationSettings(t *testing.T) { 106 | setup() 107 | defer teardown() 108 | 109 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 110 | testMethod(t, r, "GET") 111 | writeJSON(w, ` 112 | { 113 | "DescribeConfigurationSettingsResponse": {"DescribeConfigurationSettingsResult": {"ConfigurationSettings": [ 114 | { 115 | "ApplicationName": "app", 116 | "DateCreated": `+floatTime(t, "2014-02-28T00:22:21.474Z")+`, 117 | "DateUpdated": `+floatTime(t, "2014-02-28T00:33:47.684Z")+`, 118 | "DeploymentStatus": "deployed", 119 | "Description": "d", 120 | "EnvironmentName": "app-env", 121 | "OptionSettings": [ 122 | { 123 | "Namespace": "n", 124 | "OptionName": "o", 125 | "Value": "v" 126 | } 127 | ], 128 | "SolutionStackName": "64bit Amazon Linux 2013.09 running Node.js", 129 | "TemplateName": "t" 130 | } 131 | ] 132 | }}} 133 | `) 134 | }) 135 | 136 | want := ConfigurationSettings{ 137 | { 138 | ApplicationName: "app", 139 | DateCreated: mustParseTime(t, "2014-02-28T00:22:21.474Z"), 140 | DateUpdated: mustParseTime(t, "2014-02-28T00:33:47.684Z"), 141 | DeploymentStatus: "deployed", 142 | Description: "d", 143 | EnvironmentName: "app-env", 144 | OptionSettings: ConfigurationOptionSettings{ 145 | {Namespace: "n", OptionName: "o", Value: "v"}, 146 | }, 147 | SolutionStackName: "64bit Amazon Linux 2013.09 running Node.js", 148 | TemplateName: "t", 149 | }, 150 | } 151 | 152 | cs, err := client.DescribeConfigurationSettings(&DescribeConfigurationSettingsParams{}) 153 | if err != nil { 154 | t.Errorf("DescribeConfigurationSettings returned error: %v", err) 155 | } 156 | 157 | normTime(&want[0].DateCreated) 158 | normTime(&want[0].DateUpdated) 159 | if !reflect.DeepEqual(cs, want) { 160 | t.Errorf("DescribeConfigurationSettings returned %v, want %v", asJSON(t, cs), asJSON(t, want)) 161 | } 162 | } 163 | 164 | func TestConfigurationOptionSettings_Environ(t *testing.T) { 165 | got := ConfigurationOptionSettings{ 166 | {Namespace: "aws:elasticbeanstalk:application:environment", OptionName: "k1", Value: "v1"}, 167 | {Namespace: "aws:elasticbeanstalk:application:environment", OptionName: "k2", Value: "v2"}, 168 | }.Environ() 169 | want := map[string]string{"k1": "v1", "k2": "v2"} 170 | if !reflect.DeepEqual(got, want) { 171 | t.Errorf("got %v, want %v", got, want) 172 | } 173 | } 174 | 175 | func TestUpdateEnvironment(t *testing.T) { 176 | setup() 177 | defer teardown() 178 | 179 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 180 | testMethod(t, r, "POST") 181 | }) 182 | 183 | err := client.UpdateEnvironment(&UpdateEnvironmentParams{}) 184 | if err != nil { 185 | t.Errorf("UpdateEnvironment returned error: %v", err) 186 | } 187 | } 188 | 189 | func TestUpdateEnvironment_OptionSettings_Env(t *testing.T) { 190 | setup() 191 | defer teardown() 192 | 193 | wantParams := url.Values{ 194 | "Operation": []string{"UpdateEnvironment"}, 195 | "EnvironmentName": []string{"env"}, 196 | "OptionSettings.member.1.Namespace": []string{"aws:elasticbeanstalk:application:environment"}, 197 | "OptionSettings.member.1.OptionName": []string{"K0"}, 198 | "OptionSettings.member.1.Value": []string{"V0"}, 199 | "OptionSettings.member.2.Namespace": []string{"aws:elasticbeanstalk:application:environment"}, 200 | "OptionSettings.member.2.OptionName": []string{"K1"}, 201 | "OptionSettings.member.2.Value": []string{"V1"}, 202 | } 203 | 204 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 205 | testMethod(t, r, "POST") 206 | if p := r.URL.Query(); !reflect.DeepEqual(p, wantParams) { 207 | t.Errorf("UpdateEnvironment got params %# v, want %# v", pretty.Formatter(p), pretty.Formatter(wantParams)) 208 | } 209 | }) 210 | 211 | p := &UpdateEnvironmentParams{EnvironmentName: "env"} 212 | p.AddEnv("K0", "V0") 213 | p.AddEnv("K1", "V1") 214 | err := client.UpdateEnvironment(p) 215 | if err != nil { 216 | t.Errorf("UpdateEnvironment returned error: %v", err) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /cmd/ebc/ebc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "strings" 17 | "time" 18 | 19 | "github.com/crowdmob/goamz/aws" 20 | "github.com/jteeuwen/ini" 21 | "github.com/kr/s3" 22 | "github.com/kr/s3/s3util" 23 | "github.com/sqs/go-elasticbeanstalk/elasticbeanstalk" 24 | ) 25 | 26 | var dir = flag.String("dir", ".", "dir to operate in") 27 | var verbose = flag.Bool("v", false, "show verbose output") 28 | var debugKeepTempDirs = flag.Bool("debug.keep-temp-dirs", false, "(debug) don't remove temp dirs") 29 | 30 | var elasticbeanstalkURL *url.URL 31 | var ebClient *elasticbeanstalk.Client 32 | 33 | var t0 = time.Now() 34 | 35 | func initEnv() { 36 | elasticbeanstalkURLStr := os.Getenv("ELASTICBEANSTALK_URL") 37 | if elasticbeanstalkURLStr == "" { 38 | log.Fatal("Env var ELASTICBEANSTALK_URL is not set. Set it to 'https://' plus the hostname for your region at http://docs.aws.amazon.com/general/latest/gr/rande.html#elasticbeanstalk_region.") 39 | } 40 | 41 | var err error 42 | elasticbeanstalkURL, err = url.Parse(elasticbeanstalkURLStr) 43 | if err != nil { 44 | log.Fatal("Parsing ELASTICBEANSTALK_URL:", err) 45 | } 46 | 47 | region := strings.Split(elasticbeanstalkURL.Host, ".")[1] 48 | 49 | auth, err := aws.EnvAuth() 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | ebClient = &elasticbeanstalk.Client{BaseURL: elasticbeanstalkURL, Auth: auth, Region: aws.Regions[region]} 54 | } 55 | 56 | func main() { 57 | flag.Usage = func() { 58 | fmt.Fprintf(os.Stderr, "Usage: ebc command [OPTS] ARGS...\n") 59 | fmt.Fprintln(os.Stderr) 60 | fmt.Fprintln(os.Stderr, "The commands are:") 61 | fmt.Fprintln(os.Stderr) 62 | fmt.Fprintln(os.Stderr, "\tbundle\t creates a source bundle for a directory (running scripts if they exist)") 63 | fmt.Fprintln(os.Stderr, "\tdeploy\t deploys a directory") 64 | fmt.Fprintln(os.Stderr, "\tupload BUNDLE-FILE\t uploads the source bundle") 65 | fmt.Fprintln(os.Stderr) 66 | flag.PrintDefaults() 67 | fmt.Fprintln(os.Stderr) 68 | fmt.Fprintln(os.Stderr, "Environment variables:") 69 | fmt.Fprintln(os.Stderr) 70 | fmt.Fprintf(os.Stderr, "\tAWS_ACCESS_KEY_ID (is set: %v)\n", os.Getenv("AWS_ACCESS_KEY_ID") != "") 71 | fmt.Fprintf(os.Stderr, "\tAWS_SECRET_KEY (is set: %v)\n", os.Getenv("AWS_SECRET_KEY") != "") 72 | fmt.Fprintf(os.Stderr, "\tELASTICBEANSTALK_URL (current value: %q)\n", os.Getenv("ELASTICBEANSTALK_URL")) 73 | fmt.Fprintln(os.Stderr) 74 | fmt.Fprintln(os.Stderr, "Run `ebc command -h` for more information.") 75 | os.Exit(1) 76 | } 77 | 78 | flag.Parse() 79 | initEnv() 80 | 81 | var err error 82 | *dir, err = filepath.Abs(*dir) 83 | if err != nil { 84 | log.Fatal(err) 85 | } 86 | 87 | if flag.NArg() == 0 { 88 | flag.Usage() 89 | } 90 | log.SetFlags(0) 91 | 92 | subcmd := flag.Arg(0) 93 | remaining := flag.Args()[1:] 94 | switch subcmd { 95 | case "bundle": 96 | bundleCmd(remaining) 97 | case "deploy": 98 | deployCmd(remaining) 99 | case "upload": 100 | uploadCmd(remaining) 101 | } 102 | } 103 | 104 | const bundleScript = ".ebc-bundle" 105 | 106 | func bundleCmd(args []string) { 107 | fs := flag.NewFlagSet("bundle", flag.ExitOnError) 108 | outFile := fs.String("out", "eb-bundle.zip", "output file") 109 | fs.Usage = func() { 110 | fmt.Fprintf(os.Stderr, "Usage: ebc bundle\n") 111 | fmt.Fprintln(os.Stderr) 112 | fmt.Fprintf(os.Stderr, "Creates a source bundle for a directory (specified with -dir=DIR). If the directory contains an %s file, it is executed with a temporary output directory as its first argument, and it's expected to write the source bundle to that directory. Otherwise, if no %s file exists, the directory itself is used as the bundle source.", bundleScript, bundleScript) 113 | fmt.Fprintln(os.Stderr) 114 | fs.PrintDefaults() 115 | fmt.Fprintln(os.Stderr) 116 | os.Exit(1) 117 | } 118 | fs.Parse(args) 119 | 120 | if fs.NArg() != 0 { 121 | fmt.Fprintln(os.Stderr, "no positional args") 122 | fs.Usage() 123 | } 124 | 125 | fw, err := os.Create(*outFile) 126 | if err != nil { 127 | log.Fatal(err) 128 | } 129 | defer fw.Close() 130 | err = bundle(*dir, fw) 131 | if err != nil { 132 | log.Fatal("bundle failed: ", err) 133 | } 134 | 135 | fi, err := os.Stat(*outFile) 136 | if err != nil { 137 | log.Fatal(err) 138 | } 139 | fmt.Printf("Wrote bundle file: %s (%.1f MB, took %s)\n", *outFile, float64(fi.Size())/1024/1024, time.Since(t0)) 140 | } 141 | 142 | func bundle(dir string, w io.Writer) error { 143 | scriptFile := filepath.Join(dir, bundleScript) 144 | fi, err := os.Stat(scriptFile) 145 | if err == nil && fi.Mode().IsRegular() { 146 | if *verbose { 147 | log.Printf("Running bundle script %s...", scriptFile) 148 | } 149 | tmpDir, err := ioutil.TempDir("", "ebc") 150 | if err != nil { 151 | return err 152 | } 153 | if *debugKeepTempDirs { 154 | log.Printf("Writing bundle output to temp dir %s", tmpDir) 155 | } else { 156 | defer os.RemoveAll(tmpDir) 157 | } 158 | script := exec.Command(scriptFile, tmpDir) 159 | script.Dir = dir 160 | if *verbose { 161 | script.Stdout, script.Stderr = os.Stderr, os.Stderr 162 | } 163 | err = script.Run() 164 | if err != nil { 165 | return fmt.Errorf("running %s: %s", scriptFile, err) 166 | } 167 | dir = tmpDir 168 | } 169 | 170 | return writeZipArchive(dir, w) 171 | } 172 | 173 | type defaults struct { 174 | env string 175 | app string 176 | bucketURL string 177 | label string 178 | } 179 | 180 | func readDefaults(dir string) (*defaults, error) { 181 | currentBranch, err := cmdOutput(dir, "git", "rev-parse", "--abbrev-ref", "HEAD") 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | configFile := filepath.Join(dir, ".elasticbeanstalk/config") 187 | ini := ini.New() 188 | err = ini.Load(configFile) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | d := new(defaults) 194 | 195 | branchConfig := ini.Section("branch:" + currentBranch) 196 | globalConfig := ini.Section("global") 197 | var get = func(key string, default_ string) string { 198 | if branchConfig != nil { 199 | bv := branchConfig.S(key, default_) 200 | if bv != "" { 201 | return bv 202 | } 203 | } 204 | return globalConfig.S(key, default_) 205 | } 206 | 207 | d.app = get("ApplicationName", "") 208 | d.env = get("EnvironmentName", "") 209 | d.label = filepath.Base(dir) 210 | region := get("Region", "") 211 | if d.app != "" && region != "" { 212 | d.bucketURL = fmt.Sprintf("https://eb-bundle-%s.s3-%s.amazonaws.com", d.app, region) 213 | } 214 | 215 | if *verbose { 216 | log.Printf("Read defaults for branch %q from %s: %+v", currentBranch, configFile, d) 217 | } 218 | return d, nil 219 | } 220 | 221 | func cmdOutput(cwd, exe string, args ...string) (string, error) { 222 | cmd := exec.Command(exe, args...) 223 | cmd.Dir = cwd 224 | cmd.Stderr = os.Stderr 225 | out, err := cmd.Output() 226 | if err != nil { 227 | return "", err 228 | } 229 | return strings.TrimSpace(string(out)), nil 230 | } 231 | 232 | func uploadCmd(args []string) { 233 | df, err := readDefaults(*dir) 234 | if err != nil && *verbose { 235 | log.Printf("Warning: couldn't read defaults: %s", err) 236 | df = new(defaults) 237 | } 238 | 239 | fs := flag.NewFlagSet("upload", flag.ExitOnError) 240 | app := fs.String("app", df.app, "EB application name") 241 | bucket := fs.String("bucket", df.bucketURL, "S3 bucket URL (example: https://example-bucket.s3-us-west-2.amazonaws.com)") 242 | label := fs.String("label", df.label, "label base name (suffix of -0, -1, -2, etc., is appended to ensure uniqueness)") 243 | fs.Usage = func() { 244 | fmt.Fprintf(os.Stderr, "Usage: ebc upload [OPTS] BUNDLE-FILE\n") 245 | fmt.Fprintln(os.Stderr) 246 | fmt.Fprintln(os.Stderr, "Uploads the specified source bundle.") 247 | fmt.Fprintln(os.Stderr) 248 | fs.PrintDefaults() 249 | fmt.Fprintln(os.Stderr) 250 | os.Exit(1) 251 | } 252 | fs.Parse(args) 253 | 254 | if *app == "" { 255 | fmt.Fprintln(os.Stderr, "app is required") 256 | fs.Usage() 257 | } 258 | 259 | if *bucket == "" { 260 | fmt.Fprintln(os.Stderr, "bucket is required") 261 | fs.Usage() 262 | } 263 | bucketURL, err := url.Parse(*bucket) 264 | if err != nil { 265 | log.Fatal("parsing bucket URL:", err) 266 | } 267 | 268 | if *label == "" { 269 | fmt.Fprintln(os.Stderr, "label is required") 270 | fs.Usage() 271 | } 272 | 273 | if fs.NArg() != 1 { 274 | fmt.Fprintln(os.Stderr, "exactly 1 bundle file must be specified") 275 | fs.Usage() 276 | } 277 | bundleFile := fs.Arg(0) 278 | f, err := os.Open(bundleFile) 279 | if err != nil { 280 | log.Fatal(err) 281 | } 282 | 283 | fullLabel, err := upload(f, *app, bucketURL, *label) 284 | if err != nil { 285 | log.Fatal("upload failed: ", err) 286 | } 287 | fi, err := f.Stat() 288 | if err != nil { 289 | log.Fatal(err) 290 | } 291 | fmt.Printf("Uploaded %s as label %q (%.1f MB, took %s)\n", bundleFile, fullLabel, float64(fi.Size())/1024/1024, time.Since(t0)) 292 | } 293 | 294 | var s3Config = s3util.Config{ 295 | Keys: &s3.Keys{ 296 | AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"), 297 | SecretKey: os.Getenv("AWS_SECRET_KEY"), 298 | }, 299 | Service: s3.DefaultService, 300 | Client: http.DefaultClient, 301 | } 302 | 303 | func upload(r io.Reader, app string, bucketURL *url.URL, label string) (string, error) { 304 | u, fullLabel, err := makeBundleObjectURL(bucketURL, label) 305 | if err != nil { 306 | return "", fmt.Errorf("making bundle S3 object URL failed: %s", err) 307 | } 308 | 309 | if *verbose { 310 | log.Printf("Uploading source bundle to %s...", u.String()) 311 | } 312 | 313 | w, err := s3util.Create(u.String(), nil, &s3Config) 314 | if err != nil { 315 | return "", fmt.Errorf("creating S3 object failed: %s", err) 316 | } 317 | _, err = io.Copy(w, r) 318 | if err != nil { 319 | return "", err 320 | } 321 | err = w.Close() 322 | if err != nil { 323 | return "", err 324 | } 325 | 326 | if *verbose { 327 | log.Printf("Creating application version %q...", fullLabel) 328 | } 329 | params := &elasticbeanstalk.CreateApplicationVersionParams{ 330 | ApplicationName: app, 331 | VersionLabel: fullLabel, 332 | SourceBundleS3Bucket: s3BucketFromURL(u), 333 | SourceBundleS3Key: strings.TrimPrefix(u.Path, "/"), 334 | } 335 | if err := ebClient.CreateApplicationVersion(params); err != nil { 336 | return "", fmt.Errorf("creating EB application version (params: %+v): %s", params, err) 337 | } 338 | 339 | return fullLabel, nil 340 | } 341 | 342 | func s3BucketFromURL(u *url.URL) string { 343 | parts := strings.Split(u.Host, ".") 344 | if len(parts) < 3 { 345 | log.Fatalf(`Invalid S3 bucket URL %q. ebc expects a bucket url of the form "BUCKET.s3[-REGION].amazonaws.com" (such as "example-bucket.s3-us-west-2.amazonaws.com"), not "s3[-REGION].amazonaws.com/BUCKET".\n\nAlso, note that the S3 us-east-1 endpoint hostname is s3-external-1.amazonaws.com, not s3-us-east-1.amazonaws.com.`) 346 | } 347 | return parts[0] 348 | } 349 | 350 | func deployCmd(args []string) { 351 | df, err := readDefaults(*dir) 352 | if err != nil { 353 | if *verbose { 354 | log.Printf("Warning: couldn't read defaults: %s. Flag values must be explicitly specified.", err) 355 | } 356 | df = new(defaults) 357 | } 358 | 359 | fs := flag.NewFlagSet("deploy", flag.ExitOnError) 360 | env := fs.String("env", df.env, "EB environment name") 361 | app := fs.String("app", df.app, "EB application name") 362 | bucket := fs.String("bucket", df.bucketURL, "S3 bucket URL (example: https://example-bucket.s3-us-west-2.amazonaws.com)") 363 | label := fs.String("label", df.label, "label base name (suffix of -0, -1, -2, etc., is appended to ensure uniqueness)") 364 | fs.Usage = func() { 365 | fmt.Fprintf(os.Stderr, "Usage: ebc deploy [OPTS]\n") 366 | fmt.Fprintln(os.Stderr) 367 | fmt.Fprintln(os.Stderr, "Bundles and deploys a directory (specified with -dir=DIR).") 368 | fmt.Fprintln(os.Stderr) 369 | fs.PrintDefaults() 370 | fmt.Fprintln(os.Stderr) 371 | os.Exit(1) 372 | } 373 | fs.Parse(args) 374 | 375 | if *env == "" { 376 | fmt.Fprintln(os.Stderr, "env is required") 377 | fs.Usage() 378 | } 379 | 380 | if *app == "" { 381 | fmt.Fprintln(os.Stderr, "app is required") 382 | fs.Usage() 383 | } 384 | 385 | if *bucket == "" { 386 | fmt.Fprintln(os.Stderr, "bucket is required") 387 | fs.Usage() 388 | } 389 | bucketURL, err := url.Parse(*bucket) 390 | if err != nil { 391 | log.Fatal("parsing bucket URL:", err) 392 | } 393 | 394 | if *label == "" { 395 | fmt.Fprintln(os.Stderr, "label is required") 396 | fs.Usage() 397 | } 398 | 399 | if fs.NArg() != 0 { 400 | fmt.Fprintln(os.Stderr, "no positional args") 401 | fs.Usage() 402 | } 403 | 404 | if err := deploy(*dir, *env, *app, bucketURL, *label); err != nil { 405 | log.Fatal("deploy failed: ", err) 406 | } 407 | fmt.Printf("Deploy initiated (took %s)\n", time.Since(t0)) 408 | } 409 | 410 | func deploy(dir string, env, app string, bucketURL *url.URL, label string) error { 411 | var buf bytes.Buffer 412 | if err := bundle(dir, &buf); err != nil { 413 | return fmt.Errorf("bundle failed: %s", err) 414 | } 415 | 416 | fullLabel, err := upload(&buf, app, bucketURL, label) 417 | if err != nil { 418 | return fmt.Errorf("upload failed: %s", err) 419 | } 420 | 421 | p := &elasticbeanstalk.UpdateEnvironmentParams{ 422 | EnvironmentName: env, 423 | VersionLabel: fullLabel, 424 | } 425 | 426 | // Get env vars 427 | if err := setEnvVarsFromScript(dir, env, app, p); err != nil { 428 | return err 429 | } 430 | 431 | if *verbose { 432 | log.Printf("Updating environment %q to use version %q...", env, fullLabel) 433 | } 434 | 435 | if err := ebClient.UpdateEnvironment(p); err != nil { 436 | return fmt.Errorf("update environment failed: %s", err) 437 | } 438 | 439 | return nil 440 | } 441 | 442 | // setEnvVarsFromScript invokes the executable named `.ebc-vars` in dir to 443 | // obtain environment variables to set in the environment. The output is 444 | // expected to be of the form "FOO=BAR\nBAZ=QUX\n" (empty lines are ignored). 445 | func setEnvVarsFromScript(dir, env, app string, p *elasticbeanstalk.UpdateEnvironmentParams) error { 446 | scriptFile := filepath.Join(dir, ".ebc-vars") 447 | fi, err := os.Stat(scriptFile) 448 | if err == nil && fi.Mode().IsRegular() { 449 | if *verbose { 450 | log.Printf("Running vars script %s...", scriptFile) 451 | } 452 | script := exec.Command(scriptFile, env, app) 453 | script.Dir = dir 454 | if *verbose { 455 | script.Stderr = os.Stderr 456 | } 457 | out, err := script.Output() 458 | if err != nil { 459 | return fmt.Errorf("running %s: %s", scriptFile, err) 460 | } 461 | lines := bytes.Split(out, []byte("\n")) 462 | for _, line := range lines { 463 | if len(line) > 0 { 464 | parts := bytes.SplitN(line, []byte("="), 2) 465 | if len(parts) != 2 { 466 | return fmt.Errorf("invalid format for env: %q", line) 467 | } 468 | if *verbose { 469 | log.Printf(" - %s", parts[0]) 470 | } 471 | p.AddEnv(string(parts[0]), string(parts[1])) 472 | } 473 | } 474 | } 475 | return nil 476 | } 477 | 478 | // makeBundleObjectURL appends successive numeric prefixes to label until it 479 | // finds a URL that doesn't refer to an existing object. 480 | func makeBundleObjectURL(bucketURL *url.URL, label string) (*url.URL, string, error) { 481 | const max = 100 482 | for i := 0; i < max; i++ { 483 | fullLabel := fmt.Sprintf("%s-%d", label, i) 484 | u := s3URL(bucketURL, fullLabel+".zip") 485 | exists, err := s3ObjectExists(u.String()) 486 | if err != nil { 487 | return nil, "", err 488 | } 489 | if !exists { 490 | return u, fullLabel, nil 491 | } 492 | if *verbose { 493 | log.Printf("Bundle exists at %s. Trying next suffix...", u.String()) 494 | } 495 | } 496 | log.Fatal("bundles 0-%d with label %q already exist in bucket %s", max, label, bucketURL.String()) 497 | panic("unreachable") 498 | } 499 | 500 | func s3URL(bucketURL *url.URL, key string) *url.URL { 501 | return bucketURL.ResolveReference(&url.URL{Path: key}) 502 | } 503 | 504 | func s3ObjectExists(url string) (bool, error) { 505 | r, err := http.NewRequest("HEAD", url, nil) 506 | if err != nil { 507 | return false, err 508 | } 509 | r.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) 510 | s3Config.Sign(r, *s3Config.Keys) 511 | resp, err := s3Config.Client.Do(r) 512 | if err != nil { 513 | return false, err 514 | } 515 | defer resp.Body.Close() 516 | switch resp.StatusCode { 517 | case http.StatusOK: 518 | return true, nil 519 | case http.StatusNotFound: 520 | return false, nil 521 | default: 522 | return false, fmt.Errorf("unexpected HTTP status code for %s: %d %s", url, resp.StatusCode, http.StatusText(resp.StatusCode)) 523 | } 524 | } 525 | 526 | func writeZipArchive(dir string, w io.Writer) error { 527 | zip := exec.Command("zip", "-r", "-", ".") 528 | zip.Dir = dir 529 | zip.Stdout = w 530 | if *verbose { 531 | zip.Stderr = os.Stderr 532 | } 533 | err := zip.Run() 534 | if err != nil { 535 | return fmt.Errorf("writing zip archive: %s", err) 536 | } 537 | return nil 538 | } 539 | 540 | func writeZipArchive_native(paths []string, w io.Writer) error { 541 | // DISABLED: seems to be incompatible with EB's zip file reading (yields 542 | // error: "Configuration files cannot be extracted from the application 543 | // version go-eb-38. Check that the application version is a valid zip or 544 | // war file.") 545 | 546 | // expand paths so that it lists all files. 547 | var filenames []string 548 | for _, path := range paths { 549 | path = filepath.Clean(path) 550 | fi, err := os.Stat(path) 551 | if err != nil { 552 | return err 553 | } 554 | if fi.IsDir() { 555 | dirFiles, err := filesUnderDir(path) 556 | if err != nil { 557 | return err 558 | } 559 | if path != "." { 560 | filenames = append(filenames, path) 561 | } 562 | filenames = append(filenames, dirFiles...) 563 | } else { 564 | filenames = append(filenames, path) 565 | } 566 | } 567 | 568 | zw := zip.NewWriter(w) 569 | if *verbose { 570 | log.Printf("Writing %d files to source bundle...", len(filenames)) 571 | } 572 | var totalBytes int64 573 | for _, filename := range filenames { 574 | if *verbose { 575 | log.Printf("- %s", filename) 576 | } 577 | fi, err := os.Stat(filename) 578 | if err != nil { 579 | return err 580 | } 581 | h := &zip.FileHeader{Name: filename} 582 | h.SetModTime(fi.ModTime()) 583 | h.SetMode(fi.Mode()) 584 | if fi.Mode().IsDir() { 585 | h.Name += "/" 586 | } else { 587 | h.Method = zip.Deflate 588 | } 589 | f, err := zw.CreateHeader(h) 590 | if err != nil { 591 | return err 592 | } 593 | if !fi.Mode().IsDir() { 594 | file, err := os.Open(filename) 595 | if err != nil { 596 | return err 597 | } 598 | n, err := io.Copy(f, file) 599 | if err != nil { 600 | return err 601 | } 602 | totalBytes += n 603 | } 604 | } 605 | err := zw.Close() 606 | if err != nil { 607 | return err 608 | } 609 | if *verbose { 610 | log.Printf("Finished creating source bundle archive (%d MB uncompressed)", totalBytes/1024/1024) 611 | } 612 | 613 | return nil 614 | } 615 | 616 | func filesUnderDir(dir string) ([]string, error) { 617 | var files []string 618 | err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 619 | if err != nil { 620 | return err 621 | } 622 | if info.Mode().IsRegular() || info.Mode().IsDir() && path != "." { 623 | files = append(files, path) 624 | } 625 | return nil 626 | }) 627 | return files, err 628 | } 629 | --------------------------------------------------------------------------------