diff --git a/Gopkg.lock b/Gopkg.lock index 038e0d08..47259bf3 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -13,6 +13,41 @@ revision = "78439966b38d69bf38227fbf57ac8a6fee70f69a" version = "v0.4.5" +[[projects]] + name = "github.com/aws/aws-sdk-go" + packages = [ + "aws", + "aws/awserr", + "aws/awsutil", + "aws/client", + "aws/client/metadata", + "aws/corehandlers", + "aws/credentials", + "aws/credentials/ec2rolecreds", + "aws/credentials/endpointcreds", + "aws/credentials/stscreds", + "aws/csm", + "aws/defaults", + "aws/ec2metadata", + "aws/endpoints", + "aws/request", + "aws/session", + "aws/signer/v4", + "internal/sdkio", + "internal/sdkrand", + "internal/shareddefaults", + "private/protocol", + "private/protocol/ec2query", + "private/protocol/query", + "private/protocol/query/queryutil", + "private/protocol/rest", + "private/protocol/xml/xmlutil", + "service/ec2", + "service/sts" + ] + revision = "e4805606f5138247510183050abe642344275ebd" + version = "v1.14.10" + [[projects]] name = "github.com/boltdb/bolt" packages = ["."] @@ -92,6 +127,12 @@ revision = "f6c17b524822278a87e3b3bd809fec33b51f5b46" version = "v1.9.0" +[[projects]] + name = "github.com/go-ini/ini" + packages = ["."] + revision = "06f5f3d67269ccec1fe5fe4134ba6e982984f7f5" + version = "v1.37.0" + [[projects]] name = "github.com/google/go-github" packages = ["github"] @@ -122,6 +163,11 @@ packages = ["io"] revision = "d14ea06fba99483203c19d92cfcd13ebe73135f4" +[[projects]] + name = "github.com/jmespath/go-jmespath" + packages = ["."] + revision = "0b12d6b5" + [[projects]] name = "github.com/kevinburke/ssh_config" packages = ["."] @@ -326,6 +372,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "dd4e53edb336cd3acce35836e5f1a5a1803bd010b62646bc8097b22657fc2fdc" + inputs-digest = "15ca8cb1b5abf739f82069c4a5d3a30866976168a69411cd76d8d0c834e0862b" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index c531be4b..cb147c84 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -38,3 +38,7 @@ [[constraint]] name = "github.com/gorilla/websocket" version = "1.2.0" + +[[constraint]] + name = "github.com/aws/aws-sdk-go" + version = "1.14.10" diff --git a/input.go b/input.go index 125501ea..21073c4f 100644 --- a/input.go +++ b/input.go @@ -12,6 +12,8 @@ import ( ) var ( + errInvalidInput = errors.New("invalid input") + errInvalidUser = errors.New("invalid user") errInvalidAddress = errors.New("invalid IP address") errInvalidBuildType = errors.New("invalid build type") @@ -114,3 +116,60 @@ func addProjectWalkthrough(in io.Reader) (buildType string, buildFilePath string } return } + +func enterEC2CredentialsWalkthrough(in io.Reader) (id, key string, err error) { + print(`To get your credentials: + 1. Open the IAM console (https://console.aws.amazon.com/iam/home?#home). + 2. In the navigation pane of the console, choose Users. + 3. Choose your IAM user name (not the check box). + 4. Choose the Security credentials tab and then choose Create access key. + 5. To see the new access key, choose Show. Your credentials will look something like this: + + Access key ID: AKIAIOSFODNN7EXAMPLE + Secret access key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + `) + + var response string + + print("\nKey ID: ") + _, err = fmt.Fscanln(in, &response) + if err != nil { + return + } + id = response + + print("\nAccess Key: ") + _, err = fmt.Fscanln(in, &response) + if err != nil { + return + } + key = response + return +} + +func chooseFromListWalkthrough(in io.Reader, optionName string, options []string) (string, error) { + fmt.Printf("Available %ss:\n", optionName) + for _, o := range options { + println("> " + o) + } + print("Please enter your desired %s:", optionName) + + var response string + _, err := fmt.Fscanln(in, &response) + if err != nil { + return "", errInvalidInput + } + + var contains bool + for _, r := range options { + if r == response { + contains = true + break + } + } + if !contains { + return "", fmt.Errorf("invalid %s - please choose from options", optionName) + } + + return response, nil +} diff --git a/local/storage.go b/local/storage.go index 5af6b7eb..114609c6 100644 --- a/local/storage.go +++ b/local/storage.go @@ -119,3 +119,8 @@ func GetClient(name string, cmd ...*cobra.Command) (*client.Client, error) { return client, nil } + +// SaveKey writes a key to given path +func SaveKey(keyMaterial string, path string) error { + return ioutil.WriteFile(path, []byte(keyMaterial), 0644) +} diff --git a/local/storage_test.go b/local/storage_test.go index f0aee496..ddf90ba7 100644 --- a/local/storage_test.go +++ b/local/storage_test.go @@ -1,7 +1,9 @@ package local import ( + "io/ioutil" "os" + "path" "testing" "github.com/stretchr/testify/assert" @@ -72,3 +74,48 @@ func TestConfigCreateAndWriteAndRead(t *testing.T) { err = os.Remove(configPath) assert.Nil(t, err) } + +func TestSaveKey(t *testing.T) { + keyMaterial := `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAw+14SQTAidfYPDizCYPv0gWq4+wFeInCrZGo4BFbMcP7xhH+ +htmm0qx7ctYbCS0tQmCvCnt4W5jwhqH9v65/b1PWv1qQbXbJq0iyeSspgpaB8xq+ +AkWoBkUOT8iaUzESDgJfEpC9q1s7dAUpmRDD0JMVzdsv1VQqpR22VWtnpcFtAkNk +3CIXiKFYJ5677dVSrc45dhO4R67LguSPxpXNRcg26/cFKWQO+y2StnYVEEUtvoWN +z2tGQu2hftJtjzzCFXckH8VTJ8EgX0+3Co5jXEbm1idFGFgcAP1WT3xuGh+wpCXM +LYVdF18VxGzZe0bxStZ/+bhsaYfFLyU8qL7RnQIDAQABAoIBAFELWLczjQU30I1Q +ktZ7yebhS0gOaFDtAydS2j0dUNCsFehfpx5Wx8fbaxEceYB5PIB5h85ZNncFM3Et +bs4sOzBsyKbMqnNtMIx2fMTcUsZexZAu3qwH7jHxvLLJ8vQ4lxRObM88KgjIqzYZ +sJRNOAJ95QYLBaVDtIQqXzLEQ9JvDnB5++i18eIF31UXbcjvhNn4M2Goku2EZ9T8 +ny0KnRDh9W/Is6ndsBGkDEbXFVMCs6ubIeL7LdJ1W/QNK4HB3ZeRWHMR+lElp+o5 +4BY+5bQN7RrTPQmzU0lD1UAIOuPNQeUiGQs4jsV4Oz21z6AWMgg/qAjn91LaWcCH +JnDv++ECgYEA/zJbzNhxF7Kk64U1//XWhtZ3EdlbiapLq26Z10emtED9FrPJxGCz ++fDR2BwWUEpZDY3TBMmjeQeO+VN++PYGMjFogZIKNIuOhu2Qs7u92nCLyeB1aeTm +h90/5II64qCy5KN2fvU6Q2cxNNrCs0Dchh1GYYCH7+IR5NkelTQWRuUCgYEAxItZ +8JYoxfegJmK3RpzYWrbuK2tP7msA9VNSbzMdgFpLG9I+bSJPuQfdOfnhfZG/YG40 +MBpUH1X9Jn06Ie6YsbQTeEWUY4H5RKdNKSyyJYepw6C/ndRCuInGPaqQ6FSfccld +mwB3ziaIZVjSaaLGpDFaSgosW4a8hDBbe+4wvFkCgYEAhfGKmWPpSATt5uhORYBl +DvS2Hlo1X3ZQrTQp7wKejvGlZSsMddRD4qXxnjpvw8iiISkVXufus3GyK08Vz9ph +uiqQraFXVekB7/P1BUE/Ds4PsO/s8J3CGgGYrXllKtopyzO42D4iTIp3G0TO+ILM +vF/VNwvdTZ0cwz7qfGmQX7kCgYAJqOOpvGeSm0IGwPFLCihkBPudrK+IA0BPzmGN +z5BSn51zZ5jj2jza1jUcRVi8yC4EukXcW17pD1vayWrTAhwFF9mhHqJVZazvn91d ++bFjwNAqKjtgsW76DONuYnSuxoHzoLb2CEbbHe+0M3Jb+MEUjsxmOSvG789SG+JT +K/i/OQKBgQD3rq8dDSVYaLcSFwg9RfRKF+Ahtml86lm4FrfZlLEfwb6TaR/Unsh0 +XF56ZdrKh0nbOW/125RSc8STCv5klDGnBCD56Qzbin9+W6j1TWyJFMdNeaxjWK+U +lq07qdr3cY+O1F4otlDitNuhLE88dtGJM5lEyumokiH1yXwhbBtZ4w== +-----END RSA PRIVATE KEY-----` + cwd, _ := os.Getwd() + testKeyPath := path.Join(cwd, "test_key_save") + + // Write + err := SaveKey(keyMaterial, testKeyPath) + assert.Nil(t, err) + + // Read + bytes, err := ioutil.ReadFile(testKeyPath) + assert.Nil(t, err) + assert.Equal(t, keyMaterial, string(bytes)) + + // Test config remove + err = os.Remove(testKeyPath) + assert.Nil(t, err) +} diff --git a/provision.go b/provision.go new file mode 100644 index 00000000..345b9229 --- /dev/null +++ b/provision.go @@ -0,0 +1,110 @@ +package main + +// initCmd represents the init command +import ( + "os" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/ubclaunchpad/inertia/client" + "github.com/ubclaunchpad/inertia/common" + "github.com/ubclaunchpad/inertia/local" + "github.com/ubclaunchpad/inertia/provision" +) + +// Initialize "inertia" commands regarding basic configuration +func init() { + cmdProvisionECS.Flags().StringP( + "type", "t", "m3.medium", "The ec2 instance type to instantiate", + ) + cmdProvisionECS.Flags().Bool( + "from-env", false, "Load ec2 credentials from environment - requires AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to be set.", + ) + cmdProvision.AddCommand(cmdProvisionECS) + cmdRoot.AddCommand(cmdProvision) +} + +var cmdProvision = &cobra.Command{ + Use: "provision", + Short: "[BETA] Provision a new VPS setup for Inertia", + Long: `[BETA] Provision a new VPS instance set up for continuous deployment with Inertia.`, +} + +var cmdProvisionECS = &cobra.Command{ + Use: "ec2 [name]", + Short: "[BETA] Provision a new Amazon EC2 instance", + Long: `[BETA] Provision a new Amazon EC2 instance and set it up for continuous deployment + with Inertia.`, + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Ensure project initialized. + config, path, err := local.GetProjectConfigFromDisk() + if err != nil { + log.Fatal(err) + } + + // Load flags + fromEnv, _ := cmd.Flags().GetBool("from-env") + instanceType, _ := cmd.Flags().GetString("type") + + // Create VPS instance + var prov *provision.EC2Provisioner + if !fromEnv { + id, key, err := enterEC2CredentialsWalkthrough(os.Stdin) + if err != nil { + log.Fatal(err) + } + prov = provision.NewEC2Provisioner(id, key) + } else { + prov = provision.NewEC2ProvisionerFromEnv() + } + + // List regions and prompt for input + regions, err := prov.ListRegions() + if err != nil { + log.Fatal(err) + } + region, err := chooseFromListWalkthrough(os.Stdin, "region", regions) + if err != nil { + log.Fatal(err) + } + + // List image options and prompt for input + images, err := prov.ListImageOptions(region) + if err != nil { + log.Fatal(err) + } + image, err := chooseFromListWalkthrough(os.Stdin, "image", images) + if err != nil { + log.Fatal(err) + } + + // Create instance from input + remote, err := prov.CreateInstance(args[0], image, instanceType, region) + if err != nil { + log.Fatal(err) + } + + // Save new remote to configuration + remote.Branch, err = local.GetRepoCurrentBranch() + if err != nil { + log.Fatal(err) + } + config.AddRemote(remote) + config.Write(path) + + // Init the new instance + inertia, found := client.NewClient(args[0], config) + if !found { + log.Fatal("vps setup did not complete properly") + } + gitURL, err := local.GetRepoRemote("origin") + if err != nil { + log.Fatal(err) + } + err = inertia.BootstrapRemote(common.ExtractRepository(gitURL)) + if err != nil { + log.Fatal(err) + } + }, +} diff --git a/provision/doc.go b/provision/doc.go new file mode 100644 index 00000000..f3e6a31e --- /dev/null +++ b/provision/doc.go @@ -0,0 +1,2 @@ +// Package provision contains Inertia's VPS instance provisioning API +package provision diff --git a/provision/ec2.go b/provision/ec2.go new file mode 100644 index 00000000..fab7c8d8 --- /dev/null +++ b/provision/ec2.go @@ -0,0 +1,170 @@ +package provision + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/ec2metadata" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/ubclaunchpad/inertia/cfg" + "github.com/ubclaunchpad/inertia/local" +) + +// EC2Provisioner creates Amazon EC2 instances +type EC2Provisioner struct { + client *ec2.EC2 +} + +// NewEC2Provisioner creates a client to interact with Amazon EC2 using the +// given credentials +func NewEC2Provisioner(id, key string) *EC2Provisioner { + sess := session.Must(session.NewSession()) + client := ec2.New(sess, &aws.Config{ + Credentials: credentials.NewStaticCredentials(id, key, ""), + }) + return &EC2Provisioner{client: client} +} + +// NewEC2ProvisionerFromEnv creates a client to interact with Amazon EC2 using +// credentials from environment +func NewEC2ProvisionerFromEnv() *EC2Provisioner { + sess := session.Must(session.NewSession()) + client := ec2.New(sess, &aws.Config{ + Credentials: credentials.NewEnvCredentials(), + }) + return &EC2Provisioner{client: client} +} + +// ListRegions lists available regions to create an instance in +func (p *EC2Provisioner) ListRegions() ([]string, error) { + regions, err := p.client.DescribeRegions(&ec2.DescribeRegionsInput{}) + if err != nil { + return nil, err + } + + regionList := []string{} + for _, r := range regions.Regions { + regionList = append(regionList, r.GoString()) + } + return regionList, nil +} + +// ListImageOptions lists available Amazon images for your given region +func (p *EC2Provisioner) ListImageOptions(region string) ([]string, error) { + // Set requested region + p.client.Config.WithRegion(region) + + // Query for images from the Amazon + output, err := p.client.DescribeImages(&ec2.DescribeImagesInput{ + Owners: []*string{aws.String("amazon")}, + }) + if err != nil { + return nil, err + } + + // Return relevant list + images := []string{} + for _, image := range output.Images { + // todo: improve return structure + images = append(images, image.GoString()) + } + return images, nil +} + +// CreateInstance creates an EC2 instance with given properties +func (p *EC2Provisioner) CreateInstance(name, imageID, instanceType, region string) (*cfg.RemoteVPS, error) { + // Set requested region + p.client.Config.WithRegion(region) + + // Generate authentication + keyResp, err := p.client.CreateKeyPair(&ec2.CreateKeyPairInput{ + KeyName: aws.String(name + "_inertia_key"), + }) + if err != nil { + return nil, err + } + + // Save key + keyPath := filepath.Join(os.Getenv("HOME"), ".ssh", *keyResp.KeyName) + err = local.SaveKey(*keyResp.KeyMaterial, keyPath) + if err != nil { + return nil, err + } + + // Start up instance + runResp, err := p.client.RunInstances(&ec2.RunInstancesInput{ + ImageId: aws.String(imageID), + InstanceType: aws.String(instanceType), + MinCount: aws.Int64(1), + MaxCount: aws.Int64(1), + KeyName: keyResp.KeyName, + }) + if err != nil { + return nil, err + } + + // Get instance, checking every 3 seconds and occasionally asking the user + // if they would like to continue waiting + c := ec2metadata.New(session.New(), &p.client.Config) + attempts := 0 + for !c.Available() { + attempts++ + if attempts < 10 { + println("Metadata not yet available... trying again") + time.Sleep(3 * time.Second) + } else { + print("Would you like to continue waiting? (y/n)") + var response string + _, err := fmt.Scanln(&response) + print("\n") + if err != nil { + log.Fatal("Invalid response - aborting.") + } + if response == "y" { + attempts = 0 + } else { + log.Fatal("Aborting.") + break + } + } + } + + // Get metadata + publicHostname, err := c.GetMetadata("public-hostname") + if err != nil { + return nil, err + } + + // Set some instance tags for convenience + _, err = p.client.CreateTags(&ec2.CreateTagsInput{ + Resources: []*string{runResp.Instances[0].InstanceId}, + Tags: []*ec2.Tag{ + { + Key: aws.String("Name"), + Value: aws.String(name), + }, + { + Key: aws.String("Purpose"), + Value: aws.String("Inertia Continuous Deployment"), + }, + }, + }) + if err != nil { + return nil, err + } + + // Return remote configuration + return &cfg.RemoteVPS{ + Name: name, + IP: publicHostname, + User: "ec2-user", + PEM: keyPath, + SSHPort: "22", + }, nil +}