Add aws_sns_topic resource (#120)

* Docs first draft, integration tests, and constructor unit tests for SNS topic

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>

* Skeleton of SNS topic

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>

* Constructor arg validation works

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>

* Passing unit tests for recall

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>

* Subscription Count property, works

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>

* Subscription, not subscriber

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>

* Integration tests pass; also wildard ARNs are not allowed

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>

* Rubocop changes

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>

* Doc updates per kagarmoe

Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>
This commit is contained in:
Clinton Wolfe 2017-11-16 13:44:43 -05:00 committed by Dominik Richter
parent c99f7e318f
commit ab2170f717
7 changed files with 360 additions and 0 deletions

View file

@ -0,0 +1,58 @@
---
title: About the aws_sns_topic Resource
---
# aws_sns_topic
Use the `aws_sns_topic` InSpec audit resource to test properties of a single AWS Simple Notification Service Topic. SNS topics are channels for related events. AWS resources will place events in the SNS topic, while other AWS resources will _subscribe_ to receive notifications when new events have appeared.
<br>
## Syntax
# Ensure that a topic exists and has at least one subscription
describe aws_sns_topic('arn:aws:sns:*::my-topic-name') do
it { should exist }
its('confirmed_subscription_count') { should_not be_zero }
end
# You may also use has syntax to pass the ARN
describe aws_sns_topic(arn: 'arn:aws:sns:*::my-topic-name') do
it { should exist }
end
## Resource Parameters
### ARN
This resource expects a single parameter that uniquely identifes the SNS Topic, an ARN. Amazon Resource Names for SNS topics have the format `arn:aws:sns:region:account-id:topicname`. AWS requires a fully-specified ARN for looking up an SNS topic. The account ID and region are required. Wildcards are not permitted.
See also the (AWS documentation on ARNs)[http://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html].
## Matchers
### exist
Indicates that the ARN provided was found. Use should_not to test for SNS topics that should not exist.
# Expect good news
describe aws_sns_topic('arn:aws:sns:*::good-news') do
it { should exist }
end
# No bad news allowed
describe aws_sns_topic('arn:aws:sns:*::bad-news') do
it { should_not exist }
end
## Properties
### confirmed_subscription_count
An integer indicating the number of currently active subscriptions.
# Make sure someone is listening
describe aws_sns_topic('arn:aws:sns:*::my-topic-name') do
its('confirmed_subscription_count') { should_not be_zero}
end

View file

@ -14,6 +14,10 @@ class AWSConnection
Aws.config.update(opts)
end
def sns_client
@sns_client ||= Aws::SNS::Client.new
end
def ec2_resource
@ec2_resource ||= Aws::EC2::Resource.new
end

100
libraries/aws_sns_topic.rb Normal file
View file

@ -0,0 +1,100 @@
require 'aws_conn'
class AwsSnsTopic < Inspec.resource(1)
name 'aws_sns_topic'
desc 'Verifies settings for an SNS Topic'
example "
describe aws_sns_topic('arn:aws:sns:us-east-1:123456789012:some-topic') do
it { should exist }
its('confirmed_subscription_count') { should_not be_zero }
end
"
attr_reader :arn, :confirmed_subscription_count
def initialize(raw_params)
validated_params = validate_params(raw_params)
@arn = validated_params[:arn]
search
end
def exists?
@exists
end
private
def validate_params(raw_params)
# Allow passing ARN as a scalar string, not in a hash
raw_params = { arn: raw_params } if raw_params.is_a?(String)
# Remove all expected params from the raw param hash
validated_params = {}
[
:arn,
].each do |expected_param|
validated_params[expected_param] = raw_params.delete(expected_param) if raw_params.key?(expected_param)
end
# Any leftovers are unwelcome
unless raw_params.empty?
raise ArgumentError, "Unrecognized resource param '#{raw_params.keys.first}'"
end
# Validate the ARN
unless validated_params[:arn] =~ /^arn:aws:sns:[\w\-]+:\d{12}:[\S]+$/
raise ArgumentError, 'Malformed ARN for SNS topics. Expected an ARN of the form ' \
"'arn:aws:sns:REGION:ACCOUNT-ID:TOPIC-NAME'"
end
validated_params
end
def search
aws_response = AwsSnsTopic::Backend.create.get_topic_attributes(topic_arn: @arn).attributes
@exists = true
# The response has a plain hash with CamelCase plain string keys and string values
@confirmed_subscription_count = aws_response['SubscriptionsConfirmed'].to_i
rescue Aws::SNS::Errors::NotFound
@exists = false
end
class Backend
#=====================================================#
# API Definition
#=====================================================#
[
:get_topic_attributes,
].each do |method|
define_method(:method) do |*_args|
raise "Unimplemented abstract method #{method} - internal error"
end
end
#=====================================================#
# Concrete Implementation
#=====================================================#
# Uses the SDK API to really talk to AWS
class AwsClientApi < Backend
def get_topic_attributes(criteria)
AWSConnection.new.sns_client.get_topic_attributes(criteria)
end
end
#=====================================================#
# Factory Interface
#=====================================================#
# TODO: move this to a mix-in
DEFAULT_BACKEND = AwsClientApi
@selected_backend = DEFAULT_BACKEND
def self.create
@selected_backend.new
end
def self.select(klass)
@selected_backend = klass
end
end
end

View file

@ -96,3 +96,38 @@ output "example_ec2_id" {
output "no_roles_ec2_id" {
value = "${aws_instance.no_roles_instance.id}"
}
#===========================================================================#
# SNS
#===========================================================================#
# Test fixture:
# sns_test_topic_01 has one confirmed subscription
# sns_test_topic_02 has no subscriptions
resource "aws_sns_topic" "sns_test_topic_01" {
name = "${terraform.env}-test-topic-01"
}
output "sns_test_topic_01_arn" {
value = "${aws_sns_topic.sns_test_topic_01.arn}"
}
resource "aws_sqs_queue" "sqs_test_queue_01" {
name = "${terraform.env}-test-queue-01"
}
resource "aws_sns_topic_subscription" "sqs_test_queue_01_sub" {
topic_arn = "${aws_sns_topic.sns_test_topic_01.arn}"
protocol = "sqs"
endpoint = "${aws_sqs_queue.sqs_test_queue_01.arn}"
}
resource "aws_sns_topic" "sns_test_topic_02" {
name = "${terraform.env}-test-topic-02"
}
output "sns_test_topic_02_arn" {
value = "${aws_sns_topic.sns_test_topic_02.arn}"
}

View file

@ -0,0 +1,34 @@
sns_topic_with_subscription_arn = attribute(
'sns_test_topic_01_arn',
default: 'default.sns_test_topic_01_arn',
description: 'ARN of an SNS topic with at least one subscription')
sns_topic_with_no_subscriptions_arn = attribute(
'sns_test_topic_02_arn',
default: 'default.sns_test_topic_02_arn',
description: 'ARN of an SNS topic with no subscriptions')
control 'SNS Topics' do
# Split the ARNs so we can test things
scheme, partition, service, region, account, topic = sns_topic_with_subscription_arn.split(':')
arn_prefix = [scheme, partition, service].join(':')
# Search miss
no_such_topic_arn = [arn_prefix, region, account, 'no-such-topic-for-realz'].join(':')
describe aws_sns_topic(no_such_topic_arn) do
it { should_not exist }
end
# Search hit, fully specified, has subscriptions
describe aws_sns_topic(sns_topic_with_subscription_arn) do
it { should exist }
its('confirmed_subscription_count') { should_not be_zero }
end
# Search hit, fully specified, has no subscriptions
describe aws_sns_topic(sns_topic_with_no_subscriptions_arn) do
it { should exist }
its('confirmed_subscription_count') { should be_zero }
end
end

View file

@ -3,3 +3,6 @@ require 'minitest/unit'
require 'minitest/pride'
require 'inspec/resource'
# Needed for exception classes, etc
require 'aws-sdk'

View file

@ -0,0 +1,126 @@
require 'ostruct'
require 'helper'
require 'aws_sns_topic'
# MSNB = MockSnsBackend
# Abbreviation not used outside this file
#=============================================================================#
# Constructor Tests
#=============================================================================#
class AwsSnsTopicConstructorTest < Minitest::Test
def setup
AwsSnsTopic::Backend.select(AwsMSNB::NoSubscriptions)
end
def test_constructor_some_args_required
assert_raises(ArgumentError) { AwsSnsTopic.new }
end
def test_constructor_accepts_scalar_arn
AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:some-topic')
end
def test_constructor_accepts_arn_as_hash
AwsSnsTopic.new(arn: 'arn:aws:sns:us-east-1:123456789012:some-topic')
end
def test_constructor_rejects_unrecognized_resource_params
assert_raises(ArgumentError) { AwsSnsTopic.new(beep: 'boop') }
end
def test_constructor_rejects_non_arn_formats
[
'not-even-like-an-arn',
'arn:::::', # Empty
'arn::::::', # Too many colons
'arn:aws::us-east-1:123456789012:some-topic', # Omits SNS service
'arn::sns:us-east-1:123456789012:some-topic', # Omits partition
'arn:aws:sns:*:123456789012:some-topic', # All-region - not permitted for lookup
'arn:aws:sns:us-east-1::some-topic', # Default account - not permitted for lookup
].each do |example|
assert_raises(ArgumentError) { AwsSnsTopic.new(arn: example) }
end
end
end
#=============================================================================#
# Search / Recall
#=============================================================================#
class AwsSnsTopicRecallTest < Minitest::Test
# No setup here - each test needs to explicitly declare
# what they want from the backend.
def test_recall_no_match_is_no_exception
AwsSnsTopic::Backend.select(AwsMSNB::Miss)
topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:nope')
refute topic.exists?
end
def test_recall_match_single_result_works
AwsSnsTopic::Backend.select(AwsMSNB::NoSubscriptions)
topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter')
assert topic.exists?
end
end
#=============================================================================#
# Properties
#=============================================================================#
class AwsSnsTopicPropertiesTest < Minitest::Test
# No setup here - each test needs to explicitly declare
# what they want from the backend.
#---------------------------------------
# confirmed_subscription_count
#---------------------------------------
def test_prop_conf_sub_count_zero
AwsSnsTopic::Backend.select(AwsMSNB::NoSubscriptions)
topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter')
assert_equal(0, topic.confirmed_subscription_count)
end
def test_prop_conf_sub_count_zero
AwsSnsTopic::Backend.select(AwsMSNB::OneSubscription)
topic = AwsSnsTopic.new('arn:aws:sns:us-east-1:123456789012:does-not-matter')
assert_equal(1, topic.confirmed_subscription_count)
end
end
#=============================================================================#
# Test Fixtures
#=============================================================================#
module AwsMSNB
class Miss < AwsSnsTopic::Backend
def get_topic_attributes(criteria)
raise Aws::SNS::Errors::NotFound.new("No SNS topic for #{criteria[:topic_arn]}", 'Nope')
end
end
class NoSubscriptions < AwsSnsTopic::Backend
def get_topic_attributes(_criteria)
OpenStruct.new({
attributes: { # Note that this is a plain hash, odd for AWS SDK
# Many other attributes available, see
# http://docs.aws.amazon.com/sdkforruby/api/Aws/SNS/Types/GetTopicAttributesResponse.html
"SubscriptionsConfirmed" => 0
}
})
end
end
class OneSubscription < AwsSnsTopic::Backend
def get_topic_attributes(_criteria)
OpenStruct.new({
attributes: { # Note that this is a plain hash, odd for AWS SDK
# Many other attributes available, see
# http://docs.aws.amazon.com/sdkforruby/api/Aws/SNS/Types/GetTopicAttributesResponse.html
"SubscriptionsConfirmed" => 1
}
})
end
end
end