diff --git a/builtin/providers/aws/config.go b/builtin/providers/aws/config.go index 552a0f446357..a862ae834fd2 100644 --- a/builtin/providers/aws/config.go +++ b/builtin/providers/aws/config.go @@ -17,6 +17,7 @@ import ( "github.com/awslabs/aws-sdk-go/service/rds" "github.com/awslabs/aws-sdk-go/service/route53" "github.com/awslabs/aws-sdk-go/service/s3" + "github.com/awslabs/aws-sdk-go/service/sns" ) type Config struct { @@ -40,6 +41,7 @@ type AWSClient struct { rdsconn *rds.RDS iamconn *iam.IAM elasticacheconn *elasticache.ElastiCache + snsconn *sns.SNS } // Client configures and returns a fully initailized AWSClient @@ -113,6 +115,9 @@ func (c *Config) Client() (interface{}, error) { log.Println("[INFO] Initializing Elasticache Connection") client.elasticacheconn = elasticache.New(awsConfig) + + log.Println("[INFO] Initializing SNS connection") + client.snsconn = sns.New(awsConfig) } if len(errs) > 0 { diff --git a/builtin/providers/aws/provider.go b/builtin/providers/aws/provider.go index 095e392ddd14..9722597a2915 100644 --- a/builtin/providers/aws/provider.go +++ b/builtin/providers/aws/provider.go @@ -119,6 +119,7 @@ func Provider() terraform.ResourceProvider { "aws_route_table_association": resourceAwsRouteTableAssociation(), "aws_route_table": resourceAwsRouteTable(), "aws_s3_bucket": resourceAwsS3Bucket(), + "aws_sns_topic": resourceAwsSnsTopic(), "aws_security_group": resourceAwsSecurityGroup(), "aws_security_group_rule": resourceAwsSecurityGroupRule(), "aws_subnet": resourceAwsSubnet(), diff --git a/builtin/providers/aws/resource_aws_sns_topic.go b/builtin/providers/aws/resource_aws_sns_topic.go new file mode 100644 index 000000000000..5905ffd141fc --- /dev/null +++ b/builtin/providers/aws/resource_aws_sns_topic.go @@ -0,0 +1,92 @@ +package aws + +import ( + "fmt" + "log" + "strings" + + "github.com/awslabs/aws-sdk-go/aws" + "github.com/awslabs/aws-sdk-go/service/sns" + + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceAwsSnsTopic() *schema.Resource { + return &schema.Resource{ + // Topic updates are idempotent. + Create: resourceAwsSnsTopicCreate, + Update: resourceAwsSnsTopicCreate, + + Read: resourceAwsSnsTopicRead, + Delete: resourceAwsSnsTopicDelete, + + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "arn": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceAwsSnsTopicCreate(d *schema.ResourceData, meta interface{}) error { + snsconn := meta.(*AWSClient).snsconn + + createOpts := &sns.CreateTopicInput{ + Name: aws.String(d.Get("name").(string)), + } + + log.Printf("[DEBUG] Creating SNS topic") + resp, err := snsconn.CreateTopic(createOpts) + if err != nil { + return fmt.Errorf("Error creating SNS topic: %s", err) + } + + // Store the ID, in this case the ARN. + topicArn := resp.TopicARN + d.SetId(*topicArn) + log.Printf("[INFO] SNS topic ID: %s", *topicArn) + + return resourceAwsSnsTopicRead(d, meta) +} + +func resourceAwsSnsTopicRead(d *schema.ResourceData, meta interface{}) error { + snsconn := meta.(*AWSClient).snsconn + + match, err := seekSnsTopic(d.Id(), snsconn) + if err != nil { + return err + } + + if match == "" { + d.SetId("") + } else { + d.Set("arn", match) + d.Set("name", parseSnsTopicArn(match)) + } + + return nil +} + +func resourceAwsSnsTopicDelete(d *schema.ResourceData, meta interface{}) error { + snsconn := meta.(*AWSClient).snsconn + + _, err := snsconn.DeleteTopic(&sns.DeleteTopicInput{ + TopicARN: aws.String(d.Id()), + }) + if err != nil { + return fmt.Errorf("Error deleting SNS topic: %#v", err) + } + return nil +} + +// parseSnsTopicArn extracts the topic's name from its amazon resource number. +func parseSnsTopicArn(arn string) string { + parts := strings.Split(arn, ":") + return parts[len(parts)-1] +} diff --git a/builtin/providers/aws/resource_aws_sns_topic_test.go b/builtin/providers/aws/resource_aws_sns_topic_test.go new file mode 100644 index 000000000000..8c48966cb225 --- /dev/null +++ b/builtin/providers/aws/resource_aws_sns_topic_test.go @@ -0,0 +1,101 @@ +package aws + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccSnsTopic(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccAwsSnsTopicDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAwsSnsTopicConfig, + Check: resource.ComposeTestCheckFunc( + testAccAwsSnsTopic( + "aws_sns_topic.foo", + ), + ), + }, + resource.TestStep{ + Config: testAccAwsSnsTopicConfigUpdate, + Check: resource.ComposeTestCheckFunc( + testAccAwsSnsTopic( + "aws_sns_topic.foo", + ), + ), + }, + }, + }) +} + +func testAccAwsSnsTopicDestroy(s *terraform.State) error { + if len(s.RootModule().Resources) > 0 { + return fmt.Errorf("Expected all resources to be gone, but found: %#v", s.RootModule().Resources) + } + + return nil +} + +func testAccAwsSnsTopic(snsTopicResource string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[snsTopicResource] + if !ok { + return fmt.Errorf("Not found: %s", snsTopicResource) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ID is set") + } + topic, ok := s.RootModule().Resources[snsTopicResource] + if !ok { + return fmt.Errorf("Not found: %s", snsTopicResource) + } + + snsconn := testAccProvider.Meta().(*AWSClient).snsconn + + match, err := seekSnsTopic(topic.Primary.ID, snsconn) + if err != nil { + return err + } + if match == "" { + return fmt.Errorf("Not found in AWS: %s", topic) + } + + return nil + } +} + +const testAccAwsSnsTopicConfig = ` +resource "aws_sns_topic" "foo" { + name = "foo" +} +` + +// Change the name but leave the resource name the same. +const testAccAwsSnsTopicConfigUpdate = ` +resource "aws_sns_topic" "foo" { + name = "bar" +} +` + +func Test_parseSnsTopicArn(t *testing.T) { + for _, ts := range []struct { + arn string + wanted string + }{ + {"arn:aws:sns:us-east-1:123456789012:foo", "foo"}, + {"arn:aws:sns:us-west-1:123456789012:bar", "bar"}, + {"arn:aws:sns:us-east-1:123456789012:baz", "baz"}, + } { + got := parseSnsTopicArn(ts.arn) + if got != ts.wanted { + t.Fatalf("got %s; wanted %s", got, ts.wanted) + } + } +} diff --git a/builtin/providers/aws/sns_topic_seeker.go b/builtin/providers/aws/sns_topic_seeker.go new file mode 100644 index 000000000000..7c71c03c38f5 --- /dev/null +++ b/builtin/providers/aws/sns_topic_seeker.go @@ -0,0 +1,104 @@ +package aws + +import ( + "github.com/awslabs/aws-sdk-go/service/sns" +) + +// seekSnsTopic starts a topic seeker and reads out the results, looking for +// a particular ARN. +func seekSnsTopic(soughtArn string, snsconn snsTopicLister) (string, error) { + s := &snsTopicSeeker{ + lister: snsconn, + arns: make(chan string), + errc: make(chan error, 1), + } + + // launch the seeker + go s.run() + + for arn := range s.arns { + if arn == soughtArn { + return arn, nil + } + } + if err := <-s.errc; err != nil { + return "", err + } + + // We never found the ARN. + return "", nil +} + +// snsTopicLister implements ListTopics. It exists so we can mock out an SNS +// connection for the seeker in testing. +type snsTopicLister interface { + ListTopics(*sns.ListTopicsInput) (*sns.ListTopicsOutput, error) +} + +// seekerStateFn represents the state of the pager as a function that returns +// the next state. +type snsTopicSeekerStateFn func(*snsTopicSeeker) snsTopicSeekerStateFn + +// snsTopicSeeker holds the state of our SNS API scanner. +type snsTopicSeeker struct { + lister snsTopicLister // an SNS connection or mock + token *string // the token for the list topics request + respList []*sns.Topic // the list of topics in the AWS response + state snsTopicSeekerStateFn // the next state function + arns chan string // channel of topic ARNs + errc chan error // buffered error channel +} + +// run the seeker +func (s *snsTopicSeeker) run() { + for s.state = listTopics; s.state != nil; { + s.state = s.state(s) + } + close(s.arns) + close(s.errc) +} + +// emit a topic's ARN onto the arns channel +func (s *snsTopicSeeker) emit(topic *sns.Topic) { + s.arns <- *topic.TopicARN +} + +// errorf sends an error on the error channel and returns nil, stopping the +// seeker. +func (s *snsTopicSeeker) errorf(err error) snsTopicSeekerStateFn { + s.errc <- err + return nil +} + +// listTopics calls AWS for topics +func listTopics(s *snsTopicSeeker) snsTopicSeekerStateFn { + resp, err := s.lister.ListTopics(&sns.ListTopicsInput{ + NextToken: s.token, + }) + switch { + case err != nil: + return s.errorf(err) + case len(resp.Topics) == 0: + // We've no topics in SNS at all. + return nil + default: + s.respList = resp.Topics + s.token = resp.NextToken + return yieldTopic + } +} + +// yieldTopics shifts the seeker's topic list and emits the first item. +func yieldTopic(s *snsTopicSeeker) snsTopicSeekerStateFn { + topic, remaining := s.respList[0], s.respList[1:] + s.emit(topic) + s.respList = remaining + switch { + case len(s.respList) > 0: + return yieldTopic + case s.token != nil: + return listTopics + default: + return nil + } +} diff --git a/builtin/providers/aws/sns_topic_seeker_test.go b/builtin/providers/aws/sns_topic_seeker_test.go new file mode 100644 index 000000000000..fd815b112ef3 --- /dev/null +++ b/builtin/providers/aws/sns_topic_seeker_test.go @@ -0,0 +1,149 @@ +package aws + +import ( + "fmt" + "testing" + "time" + + "github.com/awslabs/aws-sdk-go/aws" + "github.com/awslabs/aws-sdk-go/service/sns" +) + +// tokenMap mimics paging of topic results. +var tokenMap = map[string]*sns.ListTopicsOutput{ + "tokenA": &sns.ListTopicsOutput{ + Topics: []*sns.Topic{ + &sns.Topic{ + TopicARN: aws.String("arn:aws:sns:us-east-1:123456789012:foo"), + }, + }, + NextToken: aws.String("tokenB"), + }, + "tokenB": &sns.ListTopicsOutput{ + Topics: []*sns.Topic{ + &sns.Topic{ + TopicARN: aws.String("arn:aws:sns:us-east-1:123456789012:bar"), + }, + }, + NextToken: aws.String("tokenC"), + }, + "tokenC": &sns.ListTopicsOutput{ + Topics: []*sns.Topic{ + &sns.Topic{ + TopicARN: aws.String("arn:aws:sns:us-east-1:123456789012:baz"), + }, + }, + }, +} + +// ltErr mimics a ListTopics error +var ltErr = fmt.Errorf("Got an error listing topics!") + +// snsConnMock implements topicLister, so we can mock result paging. +type snsConnMock struct { + topics map[string]*sns.ListTopicsOutput + err error +} + +// ListTopics returns paged results out of the tokenMap. +func (s *snsConnMock) ListTopics(input *sns.ListTopicsInput) (*sns.ListTopicsOutput, error) { + var emptyResult sns.ListTopicsOutput + + switch { + case s.topics == nil: + // return an empty result + return &emptyResult, nil + case s.err != nil: + // return a mocked response error + return nil, s.err + case input.NextToken == nil: + // we're the first call, return tokenA + return tokenMap["tokenA"], nil + default: + // we're somewhere in the pagination. + return tokenMap[*input.NextToken], nil + } +} + +func Test_snsTopicSeeker_emit(t *testing.T) { + s := &snsTopicSeeker{ + arns: make(chan string), + } + topic := &sns.Topic{ + TopicARN: aws.String("arn:aws:sns:us-east-1:123456789012:foo"), + } + + go s.emit(topic) + + // arns is a synchronous channel, so we can't simply block on a read if + // we're trying to test something sent to it. Instead, use a timeout. + timeout := make(chan bool, 1) + go func() { + time.Sleep(10 * time.Second) + timeout <- true + }() + + select { + case <-s.arns: + return + case <-timeout: + t.Fatalf("emit didn't send a topic to the 'arns' channel!") + } +} + +func Test_snsTopicSeeker_errorf(t *testing.T) { + s := &snsTopicSeeker{ + errc: make(chan error, 1), + } + + // errc is buffered. No need for a goroutine here. + s.errorf(fmt.Errorf("This is an error")) + + select { + case <-s.errc: + return + default: + } + t.Fatal("errorf didn't send an error to the 'errc' channel!") +} + +func Test_snsTopicSeeker(t *testing.T) { + for _, ts := range []struct { + name string + lister *snsConnMock + shouldErr bool + wantedLen int + }{ + {"paginated response", &snsConnMock{topics: tokenMap}, false, 3}, + {"error response", &snsConnMock{topics: tokenMap, err: ltErr}, true, 0}, + {"no topics response", &snsConnMock{}, false, 0}, + } { + // build the seeker + s := &snsTopicSeeker{ + lister: ts.lister, + arns: make(chan string), + errc: make(chan error, 1), + } + + // run the seeker + go s.run() + + // walk our response + var walkedArns []string + for arn := range s.arns { + walkedArns = append(walkedArns, arn) + } + + if len(walkedArns) != ts.wantedLen { + t.Fatalf("%s: expected %d ARNs; got %d", ts.name, ts.wantedLen, len(walkedArns)) + } + + err := <-s.errc + if ts.shouldErr && err == nil { + t.Fatalf("%s: expected error; didn't get one", ts.name) + } + if !ts.shouldErr && err != nil { + t.Fatalf("%s: expected no error; got one: %s", ts.name, err) + } + } +} diff --git a/website/source/docs/providers/aws/r/sns_topic.html.markdown b/website/source/docs/providers/aws/r/sns_topic.html.markdown new file mode 100644 index 000000000000..df708541db60 --- /dev/null +++ b/website/source/docs/providers/aws/r/sns_topic.html.markdown @@ -0,0 +1,32 @@ +--- +layout: "aws" +page_title: "AWS: aws_sns_topic" +sidebar_current: "docs-aws-resource-sns-topic" +description: |- + Provides an SNS topic +--- + +# aws\_sns\_topic + +Provides an SNS topic. + +## Example Usage + +``` +resource "aws_sns_topic" "topic" { + name = "topic" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The SNS topic's name. + +## Attribute Reference + +The following attributes are exported: + +* `name` - The name of the topic. +* `arn` - The ARN assigned by AWS to this topic. diff --git a/website/source/layouts/aws.erb b/website/source/layouts/aws.erb index bb644a0bbcf5..044d708b5d5f 100644 --- a/website/source/layouts/aws.erb +++ b/website/source/layouts/aws.erb @@ -140,6 +140,10 @@ aws_security_group_rule + > + aws_sns_topic + + > aws_subnet