diff --git a/builtin/providers/aws/config.go b/builtin/providers/aws/config.go index f8bb18589f..b8fc9fa476 100644 --- a/builtin/providers/aws/config.go +++ b/builtin/providers/aws/config.go @@ -12,6 +12,7 @@ import ( "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/service/autoscaling" + "github.com/aws/aws-sdk-go/service/cloudformation" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/cloudwatchlogs" "github.com/aws/aws-sdk-go/service/codedeploy" @@ -49,6 +50,7 @@ type Config struct { } type AWSClient struct { + cfconn *cloudformation.CloudFormation cloudwatchconn *cloudwatch.CloudWatch cloudwatchlogsconn *cloudwatchlogs.CloudWatchLogs dsconn *directoryservice.DirectoryService @@ -180,6 +182,9 @@ func (c *Config) Client() (interface{}, error) { log.Println("[INFO] Initializing Lambda Connection") client.lambdaconn = lambda.New(awsConfig) + log.Println("[INFO] Initializing Cloudformation Connection") + client.cfconn = cloudformation.New(awsConfig) + log.Println("[INFO] Initializing CloudWatch SDK connection") client.cloudwatchconn = cloudwatch.New(awsConfig) diff --git a/builtin/providers/aws/provider.go b/builtin/providers/aws/provider.go index fed004741d..5b02d4a70a 100644 --- a/builtin/providers/aws/provider.go +++ b/builtin/providers/aws/provider.go @@ -163,6 +163,7 @@ func Provider() terraform.ResourceProvider { "aws_autoscaling_group": resourceAwsAutoscalingGroup(), "aws_autoscaling_notification": resourceAwsAutoscalingNotification(), "aws_autoscaling_policy": resourceAwsAutoscalingPolicy(), + "aws_cloudformation_stack": resourceAwsCloudFormationStack(), "aws_cloudwatch_log_group": resourceAwsCloudWatchLogGroup(), "aws_autoscaling_lifecycle_hook": resourceAwsAutoscalingLifecycleHook(), "aws_cloudwatch_metric_alarm": resourceAwsCloudWatchMetricAlarm(), diff --git a/builtin/providers/aws/resource_aws_cloudformation_stack.go b/builtin/providers/aws/resource_aws_cloudformation_stack.go new file mode 100644 index 0000000000..1846a31054 --- /dev/null +++ b/builtin/providers/aws/resource_aws_cloudformation_stack.go @@ -0,0 +1,451 @@ +package aws + +import ( + "fmt" + "log" + "regexp" + "time" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/helper/schema" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/cloudformation" +) + +func resourceAwsCloudFormationStack() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsCloudFormationStackCreate, + Read: resourceAwsCloudFormationStackRead, + Update: resourceAwsCloudFormationStackUpdate, + Delete: resourceAwsCloudFormationStackDelete, + + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "template_body": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + StateFunc: normalizeJson, + }, + "template_url": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "capabilities": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + "disable_rollback": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + "notification_arns": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + "on_failure": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "parameters": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + Computed: true, + }, + "outputs": &schema.Schema{ + Type: schema.TypeMap, + Computed: true, + }, + "policy_body": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Computed: true, + StateFunc: normalizeJson, + }, + "policy_url": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "timeout_in_minutes": &schema.Schema{ + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + }, + "tags": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + ForceNew: true, + }, + }, + } +} + +func resourceAwsCloudFormationStackCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + input := cloudformation.CreateStackInput{ + StackName: aws.String(d.Get("name").(string)), + } + if v, ok := d.GetOk("template_body"); ok { + input.TemplateBody = aws.String(normalizeJson(v.(string))) + } + if v, ok := d.GetOk("template_url"); ok { + input.TemplateURL = aws.String(v.(string)) + } + if v, ok := d.GetOk("capabilities"); ok { + input.Capabilities = expandStringList(v.(*schema.Set).List()) + } + if v, ok := d.GetOk("disable_rollback"); ok { + input.DisableRollback = aws.Bool(v.(bool)) + } + if v, ok := d.GetOk("notification_arns"); ok { + input.NotificationARNs = expandStringList(v.(*schema.Set).List()) + } + if v, ok := d.GetOk("on_failure"); ok { + input.OnFailure = aws.String(v.(string)) + } + if v, ok := d.GetOk("parameters"); ok { + input.Parameters = expandCloudFormationParameters(v.(map[string]interface{})) + } + if v, ok := d.GetOk("policy_body"); ok { + input.StackPolicyBody = aws.String(normalizeJson(v.(string))) + } + if v, ok := d.GetOk("policy_url"); ok { + input.StackPolicyURL = aws.String(v.(string)) + } + if v, ok := d.GetOk("tags"); ok { + input.Tags = expandCloudFormationTags(v.(map[string]interface{})) + } + if v, ok := d.GetOk("timeout_in_minutes"); ok { + input.TimeoutInMinutes = aws.Int64(int64(v.(int))) + } + + log.Printf("[DEBUG] Creating CloudFormation Stack: %s", input) + resp, err := conn.CreateStack(&input) + if err != nil { + return fmt.Errorf("Creating CloudFormation stack failed: %s", err.Error()) + } + + d.SetId(*resp.StackId) + + wait := resource.StateChangeConf{ + Pending: []string{"CREATE_IN_PROGRESS", "ROLLBACK_IN_PROGRESS", "ROLLBACK_COMPLETE"}, + Target: "CREATE_COMPLETE", + Timeout: 30 * time.Minute, + MinTimeout: 5 * time.Second, + Refresh: func() (interface{}, string, error) { + resp, err := conn.DescribeStacks(&cloudformation.DescribeStacksInput{ + StackName: aws.String(d.Get("name").(string)), + }) + status := *resp.Stacks[0].StackStatus + log.Printf("[DEBUG] Current CloudFormation stack status: %q", status) + + if status == "ROLLBACK_COMPLETE" { + stack := resp.Stacks[0] + failures, err := getCloudFormationFailures(stack.StackName, *stack.CreationTime, conn) + if err != nil { + return resp, "", fmt.Errorf( + "Failed getting details about rollback: %q", err.Error()) + } + + return resp, "", fmt.Errorf("ROLLBACK_COMPLETE:\n%q", failures) + } + return resp, status, err + }, + } + + _, err = wait.WaitForState() + if err != nil { + return err + } + + log.Printf("[INFO] CloudFormation Stack %q created", d.Get("name").(string)) + + return resourceAwsCloudFormationStackRead(d, meta) +} + +func resourceAwsCloudFormationStackRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + stackName := d.Get("name").(string) + + input := &cloudformation.DescribeStacksInput{ + StackName: aws.String(stackName), + } + resp, err := conn.DescribeStacks(input) + if err != nil { + return err + } + + stacks := resp.Stacks + if len(stacks) < 1 { + return nil + } + + tInput := cloudformation.GetTemplateInput{ + StackName: aws.String(stackName), + } + out, err := conn.GetTemplate(&tInput) + if err != nil { + return err + } + + d.Set("template_body", normalizeJson(*out.TemplateBody)) + + stack := stacks[0] + log.Printf("[DEBUG] Received CloudFormation stack: %s", stack) + + d.Set("name", stack.StackName) + d.Set("arn", stack.StackId) + + if stack.TimeoutInMinutes != nil { + d.Set("timeout_in_minutes", int(*stack.TimeoutInMinutes)) + } + if stack.Description != nil { + d.Set("description", stack.Description) + } + if stack.DisableRollback != nil { + d.Set("disable_rollback", stack.DisableRollback) + } + if len(stack.NotificationARNs) > 0 { + err = d.Set("notification_arns", schema.NewSet(schema.HashString, flattenStringList(stack.NotificationARNs))) + if err != nil { + return err + } + } + + originalParams := d.Get("parameters").(map[string]interface{}) + err = d.Set("parameters", flattenCloudFormationParameters(stack.Parameters, originalParams)) + if err != nil { + return err + } + + err = d.Set("tags", flattenCloudFormationTags(stack.Tags)) + if err != nil { + return err + } + + err = d.Set("outputs", flattenCloudFormationOutputs(stack.Outputs)) + if err != nil { + return err + } + + if len(stack.Capabilities) > 0 { + err = d.Set("capabilities", schema.NewSet(schema.HashString, flattenStringList(stack.Capabilities))) + if err != nil { + return err + } + } + + return nil +} + +func resourceAwsCloudFormationStackUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + input := &cloudformation.UpdateStackInput{ + StackName: aws.String(d.Get("name").(string)), + } + + if d.HasChange("template_body") { + input.TemplateBody = aws.String(normalizeJson(d.Get("template_body").(string))) + } + if d.HasChange("template_url") { + input.TemplateURL = aws.String(d.Get("template_url").(string)) + } + if d.HasChange("capabilities") { + input.Capabilities = expandStringList(d.Get("capabilities").(*schema.Set).List()) + } + if d.HasChange("notification_arns") { + input.NotificationARNs = expandStringList(d.Get("notification_arns").(*schema.Set).List()) + } + if d.HasChange("parameters") { + input.Parameters = expandCloudFormationParameters(d.Get("parameters").(map[string]interface{})) + } + if d.HasChange("policy_body") { + input.StackPolicyBody = aws.String(normalizeJson(d.Get("policy_body").(string))) + } + if d.HasChange("policy_url") { + input.StackPolicyURL = aws.String(d.Get("policy_url").(string)) + } + + log.Printf("[DEBUG] Updating CloudFormation stack: %s", input) + stack, err := conn.UpdateStack(input) + if err != nil { + return err + } + + lastUpdatedTime, err := getLastCfEventTimestamp(d.Get("name").(string), conn) + if err != nil { + return err + } + + wait := resource.StateChangeConf{ + Pending: []string{ + "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "UPDATE_IN_PROGRESS", + "UPDATE_ROLLBACK_IN_PROGRESS", + "UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS", + "UPDATE_ROLLBACK_COMPLETE", + }, + Target: "UPDATE_COMPLETE", + Timeout: 15 * time.Minute, + MinTimeout: 5 * time.Second, + Refresh: func() (interface{}, string, error) { + resp, err := conn.DescribeStacks(&cloudformation.DescribeStacksInput{ + StackName: aws.String(d.Get("name").(string)), + }) + stack := resp.Stacks[0] + status := *stack.StackStatus + log.Printf("[DEBUG] Current CloudFormation stack status: %q", status) + + if status == "UPDATE_ROLLBACK_COMPLETE" { + failures, err := getCloudFormationFailures(stack.StackName, *lastUpdatedTime, conn) + if err != nil { + return resp, "", fmt.Errorf( + "Failed getting details about rollback: %q", err.Error()) + } + + return resp, "", fmt.Errorf( + "UPDATE_ROLLBACK_COMPLETE:\n%q", failures) + } + + return resp, status, err + }, + } + + _, err = wait.WaitForState() + if err != nil { + return err + } + + log.Printf("[DEBUG] CloudFormation stack %q has been updated", *stack.StackId) + + return resourceAwsCloudFormationStackRead(d, meta) +} + +func resourceAwsCloudFormationStackDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + input := &cloudformation.DeleteStackInput{ + StackName: aws.String(d.Get("name").(string)), + } + log.Printf("[DEBUG] Deleting CloudFormation stack %s", input) + _, err := conn.DeleteStack(input) + if err != nil { + awsErr, ok := err.(awserr.Error) + if !ok { + return err + } + + if awsErr.Code() == "ValidationError" { + // Ignore stack which has been already deleted + return nil + } + return err + } + + wait := resource.StateChangeConf{ + Pending: []string{"DELETE_IN_PROGRESS", "ROLLBACK_IN_PROGRESS"}, + Target: "DELETE_COMPLETE", + Timeout: 30 * time.Minute, + MinTimeout: 5 * time.Second, + Refresh: func() (interface{}, string, error) { + resp, err := conn.DescribeStacks(&cloudformation.DescribeStacksInput{ + StackName: aws.String(d.Get("name").(string)), + }) + + if err != nil { + awsErr, ok := err.(awserr.Error) + if !ok { + return resp, "DELETE_FAILED", err + } + + log.Printf("[DEBUG] Error when deleting CloudFormation stack: %s: %s", + awsErr.Code(), awsErr.Message()) + + if awsErr.Code() == "ValidationError" { + return resp, "DELETE_COMPLETE", nil + } + } + + if len(resp.Stacks) == 0 { + log.Printf("[DEBUG] CloudFormation stack %q is already gone", d.Get("name")) + return resp, "DELETE_COMPLETE", nil + } + + status := *resp.Stacks[0].StackStatus + log.Printf("[DEBUG] Current CloudFormation stack status: %q", status) + + return resp, status, err + }, + } + + _, err = wait.WaitForState() + if err != nil { + return err + } + + log.Printf("[DEBUG] CloudFormation stack %q has been deleted", d.Id()) + + d.SetId("") + + return nil +} + +// getLastCfEventTimestamp takes the first event in a list +// of events ordered from the newest to the oldest +// and extracts timestamp from it +// LastUpdatedTime only provides last >successful< updated time +func getLastCfEventTimestamp(stackName string, conn *cloudformation.CloudFormation) ( + *time.Time, error) { + output, err := conn.DescribeStackEvents(&cloudformation.DescribeStackEventsInput{ + StackName: aws.String(stackName), + }) + if err != nil { + return nil, err + } + + return output.StackEvents[0].Timestamp, nil +} + +// getCloudFormationFailures returns ResourceStatusReason(s) +// of events that should be failures based on regexp match of status +func getCloudFormationFailures(stackName *string, afterTime time.Time, + conn *cloudformation.CloudFormation) ([]string, error) { + var failures []string + // Only catching failures from last 100 events + // Some extra iteration logic via NextToken could be added + // but in reality it's nearly impossible to generate >100 + // events by a single stack update + events, err := conn.DescribeStackEvents(&cloudformation.DescribeStackEventsInput{ + StackName: stackName, + }) + + if err != nil { + return nil, err + } + + failRe := regexp.MustCompile("_FAILED$") + rollbackRe := regexp.MustCompile("^ROLLBACK_") + + for _, e := range events.StackEvents { + if (failRe.MatchString(*e.ResourceStatus) || rollbackRe.MatchString(*e.ResourceStatus)) && + e.Timestamp.After(afterTime) && e.ResourceStatusReason != nil { + failures = append(failures, *e.ResourceStatusReason) + } + } + + return failures, nil +} diff --git a/builtin/providers/aws/resource_aws_cloudformation_stack_test.go b/builtin/providers/aws/resource_aws_cloudformation_stack_test.go new file mode 100644 index 0000000000..7ad24be344 --- /dev/null +++ b/builtin/providers/aws/resource_aws_cloudformation_stack_test.go @@ -0,0 +1,228 @@ +package aws + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudformation" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccAWSCloudFormation_basic(t *testing.T) { + var stack cloudformation.Stack + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSCloudFormationDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAWSCloudFormationConfig, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackExists("aws_cloudformation_stack.network", &stack), + ), + }, + }, + }) +} + +func TestAccAWSCloudFormation_defaultParams(t *testing.T) { + var stack cloudformation.Stack + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSCloudFormationDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAWSCloudFormationConfig_defaultParams, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackExists("aws_cloudformation_stack.asg-demo", &stack), + ), + }, + }, + }) +} + +func TestAccAWSCloudFormation_allAttributes(t *testing.T) { + var stack cloudformation.Stack + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSCloudFormationDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAWSCloudFormationConfig_allAttributes, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackExists("aws_cloudformation_stack.full", &stack), + ), + }, + }, + }) +} + +func testAccCheckCloudFormationStackExists(n string, stack *cloudformation.Stack) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + rs = rs + return fmt.Errorf("Not found: %s", n) + } + + conn := testAccProvider.Meta().(*AWSClient).cfconn + params := &cloudformation.DescribeStacksInput{ + StackName: aws.String(rs.Primary.ID), + } + resp, err := conn.DescribeStacks(params) + if err != nil { + return err + } + if len(resp.Stacks) == 0 { + return fmt.Errorf("CloudFormation stack not found") + } + + return nil + } +} + +func testAccCheckAWSCloudFormationDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).cfconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_cloudformation_stack" { + continue + } + + params := cloudformation.DescribeStacksInput{ + StackName: aws.String(rs.Primary.ID), + } + + resp, err := conn.DescribeStacks(¶ms) + + if err == nil { + if len(resp.Stacks) != 0 && + *resp.Stacks[0].StackId == rs.Primary.ID { + return fmt.Errorf("CloudFormation stack still exists: %q", rs.Primary.ID) + } + } + } + + return nil +} + +var testAccAWSCloudFormationConfig = ` +resource "aws_cloudformation_stack" "network" { + name = "tf-networking-stack" + template_body = <AWS Provider + > + CloudFormation Resources + + > CloudWatch Resources