Exemple #1
0
func cmdInit(c *cli.Context) error {
	ep := stdcli.QOSEventProperties{Start: time.Now()}

	distinctId, err := currentId()
	if err != nil {
		stdcli.QOSEventSend("cli-init", distinctId, stdcli.QOSEventProperties{Error: err})
	}

	wd := "."

	if len(c.Args()) > 0 {
		wd = c.Args()[0]
	}

	dir, _, err := stdcli.DirApp(c, wd)
	if err != nil {
		return stdcli.QOSEventSend("cli-init", distinctId, stdcli.QOSEventProperties{Error: err})
	}

	// TODO parse the Dockerfile and build a docker-compose.yml
	if exists("docker-compose.yml") {
		return stdcli.Error(fmt.Errorf("Cannot initialize a project that already contains a docker-compose.yml"))
	}

	err = initApplication(dir)
	if err != nil {
		return stdcli.QOSEventSend("cli-init", distinctId, stdcli.QOSEventProperties{Error: err})
	}

	return stdcli.QOSEventSend("cli-init", distinctId, ep)
}
Exemple #2
0
func waitForAvailability(url string) error {
	tick := time.Tick(10 * time.Second)
	timeout := time.After(20 * time.Minute)

	for {
		select {
		case <-tick:
			fmt.Print(".")

			client := &http.Client{
				Timeout: 2 * time.Second,
			}

			_, err := client.Get(url)

			if err == nil {
				return nil
			}
		case <-timeout:
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: fmt.Errorf("timeout")})
			return fmt.Errorf("timeout")
		}
	}

	return fmt.Errorf("unknown error")
}
Exemple #3
0
/// validateUserAccess checks for the "AdministratorAccess" policy needed to create a rack.
func validateUserAccess(region string, creds *AwsCredentials) error {

	// this validation need to check for actual permissions somehow and not
	// just a policy name
	return nil

	Iam := iam.New(session.New(), awsConfig(region, creds))

	userOutput, err := Iam.GetUser(&iam.GetUserInput{})
	if err != nil {
		if ae, ok := err.(awserr.Error); ok {
			return fmt.Errorf("%s. See %s", ae.Code(), iamUserURL)
		}
		return fmt.Errorf("%s. See %s", err, iamUserURL)
	}

	policies, err := Iam.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{
		UserName: userOutput.User.UserName,
	})
	if err != nil {
		if ae, ok := err.(awserr.Error); ok {
			return fmt.Errorf("%s. See %s", ae.Code(), iamUserURL)
		}
	}

	for _, policy := range policies.AttachedPolicies {
		if "AdministratorAccess" == *policy.PolicyName {
			return nil
		}
	}

	msg := fmt.Errorf("Administrator access needed. See %s", iamUserURL)
	stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: msg})
	return stdcli.Error(msg)
}
Exemple #4
0
func waitForCompletion(stack string, CloudFormation *cloudformation.CloudFormation, isDeleting bool) (string, error) {
	for {
		dres, err := CloudFormation.DescribeStacks(&cloudformation.DescribeStacksInput{
			StackName: aws.String(stack),
		})
		if err != nil {
			return "", err
		}

		err = displayProgress(stack, CloudFormation, isDeleting)
		if err != nil {
			return "", err
		}

		if len(dres.Stacks) != 1 {
			return "", fmt.Errorf("could not read stack status")
		}

		switch *dres.Stacks[0].StackStatus {
		case "CREATE_COMPLETE":
			for _, o := range dres.Stacks[0].Outputs {
				if *o.OutputKey == "Dashboard" {
					return *o.OutputValue, nil
				}
			}

			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
			return "", fmt.Errorf("could not install stack, contact [email protected] for assistance")
		case "CREATE_FAILED":
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
			return "", fmt.Errorf("stack creation failed, contact [email protected] for assistance")
		case "ROLLBACK_COMPLETE":
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
			return "", fmt.Errorf("stack creation failed, contact [email protected] for assistance")
		case "DELETE_COMPLETE":
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
			return "", nil
		case "DELETE_FAILED":
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
			return "", fmt.Errorf("stack deletion failed, contact [email protected] for assistance")
		}

		time.Sleep(2 * time.Second)
	}
}
Exemple #5
0
func cmdStart(c *cli.Context) error {
	// go handleResize()

	id, err := currentId()
	if err != nil {
		stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{Error: err})
	}

	err = dockerTest()
	if err != nil {
		return stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{ValidationError: err})
	}

	dir, app, err := stdcli.DirApp(c, filepath.Dir(c.String("file")))
	if err != nil {
		return stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{ValidationError: err})
	}

	appType := detectApplication(dir)
	m, err := manifest.LoadFile(c.String("file"))
	if err != nil {
		return stdcli.ExitError(err)
	}

	if err := m.Shift(c.Int("shift")); err != nil {
		return stdcli.ExitError(err)
	}

	if pcc, err := m.PortConflicts(); err != nil || len(pcc) > 0 {
		if err == nil {
			err = fmt.Errorf("ports in use: %v", pcc)
		}
		stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{
			ValidationError: err,
			AppType:         appType,
		})

		return stdcli.ExitError(err)
	}

	cache := !c.Bool("no-cache")
	sync := !c.Bool("no-sync")

	r := m.Run(dir, app, cache, sync)

	err = r.Start()
	if err != nil {
		return stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{
			ValidationError: err,
			AppType:         appType,
		})
	}

	stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{
		AppType: appType,
	})

	go handleInterrupt(r)

	return r.Wait()
}
Exemple #6
0
func cmdStart(c *cli.Context) error {
	// go handleResize()
	var service string
	var command []string

	if len(c.Args()) > 0 {
		service = c.Args()[0]
	}

	if len(c.Args()) > 1 {
		command = c.Args()[1:]
	}

	id, err := currentId()
	if err != nil {
		stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{Error: err})
	}

	err = dockerTest()
	if err != nil {
		stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{ValidationError: err})
		return stdcli.Error(err)
	}

	dir, app, err := stdcli.DirApp(c, filepath.Dir(c.String("file")))
	if err != nil {
		stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{ValidationError: err})
		return stdcli.Error(err)
	}

	appType := detectApplication(dir)
	m, err := manifest.LoadFile(c.String("file"))
	if err != nil {
		return stdcli.Error(err)
	}

	errs := m.Validate()
	if len(errs) > 0 {
		for _, e := range errs[1:] {
			stdcli.Error(e)
		}
		return stdcli.Error(errs[0])
	}

	if service != "" {
		_, ok := m.Services[service]
		if !ok {
			return stdcli.Error(fmt.Errorf("Service %s not found in manifest", service))
		}
	}

	if err := m.Shift(c.Int("shift")); err != nil {
		return stdcli.Error(err)
	}

	// one-off commands don't need port validation
	if len(command) == 0 {
		if pcc, err := m.PortConflicts(); err != nil || len(pcc) > 0 {
			if err == nil {
				err = fmt.Errorf("ports in use: %v", pcc)
			}
			stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{
				ValidationError: err,
				AppType:         appType,
			})
			return stdcli.Error(err)
		}
	}

	cache := !c.Bool("no-cache")
	sync := !c.Bool("no-sync")

	r := m.Run(dir, app, manifest.RunOptions{
		Cache:   cache,
		Sync:    sync,
		Service: service,
		Command: command,
	})

	err = r.Start()
	if err != nil {
		r.Stop()

		stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{
			ValidationError: err,
			AppType:         appType,
		})
		return stdcli.Error(err)
	}

	stdcli.QOSEventSend("cli-start", id, stdcli.QOSEventProperties{
		AppType: appType,
	})

	// Setup the local "cron jobs"
	for _, entry := range m.Services {
		labels := entry.LabelsByPrefix("convox.cron")
		processName := fmt.Sprintf("%s-%s", app, entry.Name)
		c := cron.New()

		for key, value := range labels {
			p, ok := r.Processes[processName]
			if !ok {
				continue
			}
			cronjob := models.NewCronJobFromLabel(key, value)

			rs := strings.NewReplacer("cron(", "0 ", ")", "")                 // cron pkg first field is in seconds so set to 0
			trigger := strings.TrimSuffix(rs.Replace(cronjob.Schedule), " *") // and doesn't recognize year so we trim it

			c.AddFunc(trigger, func() {
				cronProccesName := fmt.Sprintf("cron-%s-%04d", cronjob.Name, rand.Intn(9000))
				// Replace args with cron specific ones
				cronArgs := p.GenerateArgs(&manifest.ArgOptions{
					Command:     cronjob.Command,
					IgnorePorts: true,
					Name:        cronProccesName,
				})

				done := make(chan error)
				manifest.RunAsync(
					r.Output.Stream(cronProccesName),
					manifest.Docker(append([]string{"run"}, cronArgs...)...),
					done,
				)

				err := <-done
				if err != nil {
					fmt.Printf("error running %s: %s\n", cronProccesName, err.Error())
				}
			})
		}

		c.Start()
	}

	go handleInterrupt(r)

	return r.Wait()
}
Exemple #7
0
func cmdUninstall(c *cli.Context) error {
	ep := stdcli.QOSEventProperties{Start: time.Now()}

	distinctId, err := currentId()
	if err != nil {
		return stdcli.QOSEventSend("cli-uninstall", distinctId, stdcli.QOSEventProperties{Error: err})
	}

	if len(c.Args()) != 2 && len(c.Args()) != 3 {
		stdcli.Usage(c, "uninstall")
		return nil
	}

	rackName := c.Args()[0]
	region := c.Args()[1]

	credentialsFile := ""
	if len(c.Args()) == 3 {
		credentialsFile = c.Args()[2]
	}

	fmt.Println(Banner)

	creds, err := readCredentials(credentialsFile)
	if err != nil {
		return stdcli.QOSEventSend("cli-uninstall", distinctId, stdcli.QOSEventProperties{Error: err})
	}
	if creds == nil {
		return stdcli.Error(fmt.Errorf("error reading credentials"))
	}

	CF := cloudformation.New(session.New(), awsConfig(region, creds))
	S3 := s3.New(session.New(), awsConfig(region, creds))

	stacks, err := describeRackStacks(rackName, distinctId, CF)
	if err != nil {
		return stdcli.QOSEventSend("cli-uninstall", distinctId, stdcli.QOSEventProperties{Error: err})
	}

	// verify that rack was detected
	if len(stacks.Rack) == 0 || stacks.Rack[0].StackName != rackName {
		return stdcli.Error(fmt.Errorf("Can not find rack named %s.", rackName))
	}

	fmt.Println("Resources to delete:\n")

	// display all the services, apps, then rack
	t := stdcli.NewTable("STACK", "TYPE", "STATUS")

	for _, s := range stacks.Services {
		t.AddRow(s.Name, s.Type, s.Status)
	}

	for _, s := range stacks.Apps {
		t.AddRow(s.Name, s.Type, s.Status)
	}

	t.AddRow(stacks.Rack[0].Name, "rack", stacks.Rack[0].Status)

	t.Print()
	fmt.Println()

	// verify that no stack is being updated
	for _, s := range stacks.all() {
		if strings.HasSuffix(s.Status, "IN_PROGRESS") {
			return stdcli.Error(fmt.Errorf("Can not uninstall while %s is updating.", s.StackName))
		}
	}

	// prompt to confirm rack name
	reader := bufio.NewReader(os.Stdin)

	if !c.Bool("force") {
		if terminal.IsTerminal(int(os.Stdin.Fd())) {
			fmt.Printf("Delete everything? y/N: ")

			confirm, err := reader.ReadString('\n')
			if err != nil {
				return stdcli.QOSEventSend("cli-uninstall", distinctId, stdcli.QOSEventProperties{Error: err})
			}

			if strings.TrimSpace(confirm) != "y" {
				return stdcli.Error(fmt.Errorf("Aborting uninstall."))
			}
		} else {
			return stdcli.Error(fmt.Errorf("Aborting uninstall. Use the --force for non-interactive uninstall."))
		}
	}

	fmt.Println("")

	fmt.Println("Uninstalling Convox...")

	// CF Stack Delete and Retry could take 30+ minutes. Periodically generate more progress output.
	go func() {
		t := time.Tick(2 * time.Minute)
		for range t {
			fmt.Println("Uninstalling Convox...")
		}
	}()

	success := true

	// Delete all Service, App and Rack stacks
	err = deleteStacks("service", rackName, distinctId, CF)
	if err != nil {
		stdcli.QOSEventSend("cli-uninstall", distinctId, stdcli.QOSEventProperties{Error: err})
		success = false
	}

	err = deleteStacks("app", rackName, distinctId, CF)
	if err != nil {
		stdcli.QOSEventSend("cli-uninstall", distinctId, stdcli.QOSEventProperties{Error: err})
		success = false
	}

	err = deleteStacks("rack", rackName, distinctId, CF)
	if err != nil {
		stdcli.QOSEventSend("cli-uninstall", distinctId, stdcli.QOSEventProperties{Error: err})
		success = false
	}

	// Delete all S3 buckets
	wg := new(sync.WaitGroup)

	for _, s := range stacks.Apps {
		for _, b := range s.Buckets {
			wg.Add(1)
			go deleteBucket(b, wg, S3)
		}
	}

	for _, s := range stacks.Rack {
		for _, b := range s.Buckets {
			wg.Add(1)
			go deleteBucket(b, wg, S3)
		}
	}

	wg.Wait()

	// Clean up ~/.convox
	host := stacks.Rack[0].Outputs["Dashboard"]

	if configuredHost, _ := currentHost(); configuredHost == host {
		err = removeHost()
		if err != nil {
			return stdcli.QOSEventSend("cli-uninstall", distinctId, stdcli.QOSEventProperties{Error: err})
		}
	}

	err = removeLogin(host)
	if err != nil {
		return stdcli.QOSEventSend("cli-uninstall", distinctId, stdcli.QOSEventProperties{Error: err})
	}

	if success {
		fmt.Println("Successfully uninstalled.")
	} else {
		return stdcli.Error(fmt.Errorf("Uninstall encountered some errors, contact [email protected] for assistance"))
	}

	return stdcli.QOSEventSend("cli-uninstall", distinctId, ep)
}
Exemple #8
0
func cmdInstall(c *cli.Context) error {
	ep := stdcli.QOSEventProperties{Start: time.Now()}

	region := c.String("region")

	stackName := c.String("stack-name")
	awsRegexRules := []string{
		//ecr: http://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_CreateRepository.html
		"(?:[a-z0-9]+(?:[._-][a-z0-9]+)*/)*[a-z0-9]+(?:[._-][a-z0-9]+)*",
		//cloud formation: https://forums.aws.amazon.com/thread.jspa?threadID=118427
		"[a-zA-Z][-a-zA-Z0-9]*",
	}

	for _, r := range awsRegexRules {
		rp := regexp.MustCompile(r)
		matchedStr := rp.FindString(stackName)
		match := len(matchedStr) == len(stackName)

		if !match {
			msg := fmt.Errorf("Stack name '%s' is invalid, must match [a-z0-9-]*", stackName)
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{ValidationError: msg})
			return stdcli.Error(msg)
		}
	}

	tenancy := "default"
	instanceType := c.String("instance-type")

	if c.Bool("dedicated") {
		tenancy = "dedicated"
		if strings.HasPrefix(instanceType, "t2") {
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{ValidationError: fmt.Errorf("t2 instance types aren't supported in dedicated tenancy, please set --instance-type.")})
			return stdcli.Error(fmt.Errorf("t2 instance types aren't supported in dedicated tenancy, please set --instance-type."))
		}
	}

	numInstances := c.Int("instance-count")
	instanceCount := fmt.Sprintf("%d", numInstances)
	if numInstances <= 2 {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{ValidationError: fmt.Errorf("instance-count must be greater than 2")})
		return stdcli.Error(fmt.Errorf("instance-count must be greater than 2"))
	}

	var subnet0CIDR, subnet1CIDR, subnet2CIDR string

	if cidrs := c.String("subnet-cidrs"); cidrs != "" {
		parts := strings.SplitN(cidrs, ",", 3)
		if len(parts) < 3 {
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{ValidationError: fmt.Errorf("subnet-cidrs must have 3 values")})
			return stdcli.Error(fmt.Errorf("subnet-cidrs must have 3 values"))
		}

		subnet0CIDR = parts[0]
		subnet1CIDR = parts[1]
		subnet2CIDR = parts[2]
	}

	var subnetPrivate0CIDR, subnetPrivate1CIDR, subnetPrivate2CIDR string

	if cidrs := c.String("private-cidrs"); cidrs != "" {
		parts := strings.SplitN(cidrs, ",", 3)
		if len(parts) < 3 {
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{ValidationError: fmt.Errorf("private-cidrs must have 3 values")})
			return stdcli.Error(fmt.Errorf("private-cidrs must have 3 values"))
		}

		subnetPrivate0CIDR = parts[0]
		subnetPrivate1CIDR = parts[1]
		subnetPrivate2CIDR = parts[2]
	}

	var existingVPC string

	if vpc := c.String("existing-vpc"); vpc != "" {
		existingVPC = vpc
	}

	internetGateway := c.String("internet-gateway")

	if (existingVPC != "") && (internetGateway == "") {
		return stdcli.Error(fmt.Errorf("must specify valid Internet Gateway for existing VPC"))
	}

	private := "No"
	if c.Bool("private") || strings.ToLower(os.Getenv("RACK_PRIVATE")) == "yes" || strings.ToLower(os.Getenv("RACK_PRIVATE")) == "true" {
		private = "Yes"
	}

	ami := c.String("ami")

	key := c.String("key")

	vpcCIDR := c.String("vpc-cidr")

	versions, err := version.All()
	if err != nil {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: fmt.Errorf("error getting versions: %s", err)})
		return stdcli.Error(err)
	}

	version, err := versions.Resolve(c.String("version"))
	if err != nil {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: fmt.Errorf("error resolving version: %s", err)})
		return stdcli.Error(err)
	}

	versionName := version.Version
	furl := fmt.Sprintf(formationURL, versionName)

	fmt.Println(Banner)

	if err != nil {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
		return stdcli.Error(err)
	}

	fmt.Printf("Installing Convox (%s)...\n", versionName)

	if private == "Yes" {
		fmt.Println("(Private Network Edition)")
	}

	reader := bufio.NewReader(os.Stdin)

	if email := c.String("email"); email != "" {
		distinctID = email
		updateId(distinctID)
	} else if terminal.IsTerminal(int(os.Stdin.Fd())) {
		fmt.Print("Email Address (optional, to receive project updates): ")

		email, err := reader.ReadString('\n')
		if err != nil {
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
			return stdcli.Error(err)
		}

		if strings.TrimSpace(email) != "" {
			distinctID = email
			updateId(email)
		}
	}

	credentialsFile := ""
	if len(c.Args()) >= 1 {
		credentialsFile = c.Args()[0]
	}

	creds, err := readCredentials(credentialsFile)
	if err != nil {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: fmt.Errorf("error: %s", err)})
		return stdcli.Error(err)
	}
	if creds == nil {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: fmt.Errorf("error reading credentials")})
		return stdcli.Error(err)
	}

	err = validateUserAccess(region, creds)
	if err != nil {
		stdcli.Error(err)
	}

	password := c.String("password")
	if password == "" {
		password = randomString(30)
	}

	CloudFormation := cloudformation.New(session.New(), awsConfig(region, creds))

	req := &cloudformation.CreateStackInput{
		Capabilities: []*string{aws.String("CAPABILITY_IAM")},
		Parameters: []*cloudformation.Parameter{
			{ParameterKey: aws.String("Ami"), ParameterValue: aws.String(ami)},
			{ParameterKey: aws.String("ClientId"), ParameterValue: aws.String(distinctID)},
			{ParameterKey: aws.String("ExistingVpc"), ParameterValue: aws.String(existingVPC)},
			{ParameterKey: aws.String("InstanceCount"), ParameterValue: aws.String(instanceCount)},
			{ParameterKey: aws.String("InstanceType"), ParameterValue: aws.String(instanceType)},
			{ParameterKey: aws.String("InternetGateway"), ParameterValue: aws.String(internetGateway)},
			{ParameterKey: aws.String("Key"), ParameterValue: aws.String(key)},
			{ParameterKey: aws.String("Password"), ParameterValue: aws.String(password)},
			{ParameterKey: aws.String("Private"), ParameterValue: aws.String(private)},
			{ParameterKey: aws.String("Tenancy"), ParameterValue: aws.String(tenancy)},
			{ParameterKey: aws.String("Version"), ParameterValue: aws.String(versionName)},
			{ParameterKey: aws.String("Subnet0CIDR"), ParameterValue: aws.String(subnet0CIDR)},
			{ParameterKey: aws.String("Subnet1CIDR"), ParameterValue: aws.String(subnet1CIDR)},
			{ParameterKey: aws.String("Subnet2CIDR"), ParameterValue: aws.String(subnet2CIDR)},
			{ParameterKey: aws.String("SubnetPrivate0CIDR"), ParameterValue: aws.String(subnetPrivate0CIDR)},
			{ParameterKey: aws.String("SubnetPrivate1CIDR"), ParameterValue: aws.String(subnetPrivate1CIDR)},
			{ParameterKey: aws.String("SubnetPrivate2CIDR"), ParameterValue: aws.String(subnetPrivate2CIDR)},
			{ParameterKey: aws.String("VPCCIDR"), ParameterValue: aws.String(vpcCIDR)},
		},
		StackName:   aws.String(stackName),
		TemplateURL: aws.String(furl),
	}

	if tf := os.Getenv("TEMPLATE_FILE"); tf != "" {
		dat, err := ioutil.ReadFile(tf)
		if err != nil {
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: fmt.Errorf("error reading template file: %s", tf)})
			return stdcli.Error(err)
		}

		t := new(bytes.Buffer)
		if err := json.Compact(t, dat); err != nil {
			stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
			return stdcli.Error(err)
		}

		req.TemplateURL = nil
		req.TemplateBody = aws.String(t.String())
	}

	res, err := CloudFormation.CreateStack(req)
	if err != nil {
		if awsErr, ok := err.(awserr.Error); ok {
			if awsErr.Code() == "AlreadyExistsException" {
				stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
				return stdcli.Error(fmt.Errorf("Stack %q already exists. Run `convox uninstall` then try again", stackName))
			}
		}

		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
		return stdcli.Error(err)
	}

	// NOTE: we start making lots of network requests here
	//			 so we're just going to return for testability
	if os.Getenv("AWS_REGION") == "test" {
		fmt.Println(*res.StackId)
		return nil
	}

	host, err := waitForCompletion(*res.StackId, CloudFormation, false)
	if err != nil {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
		return stdcli.Error(err)
	}

	fmt.Println("Waiting for load balancer...")

	if err := waitForAvailability(fmt.Sprintf("http://%s/", host)); err != nil {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
		return stdcli.Error(err)
	}

	fmt.Println("Logging in...")

	err = addLogin(host, password)
	if err != nil {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
		return stdcli.Error(err)
	}

	err = switchHost(host)
	if err != nil {
		stdcli.QOSEventSend("cli-install", distinctID, stdcli.QOSEventProperties{Error: err})
		return stdcli.Error(err)
	}

	fmt.Println("Success, try `convox apps`")

	return stdcli.QOSEventSend("cli-install", distinctID, ep)
}